Django2-Web-应用构建指南-全-

Django2 Web 应用构建指南(全)

原文:zh.annas-archive.org/md5/18689E1989723338A1936B680A71254B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

谁没有一个想要推出的下一个伟大应用或服务的想法?然而,大多数应用程序、服务和网站最终都依赖于服务器能够接受请求,然后根据这些请求创建、读取、更新和删除记录。Django 使得构建和启动网站、服务和后端变得容易。然而,尽管它在大规模成功的初创公司和企业中被使用的历史,但要收集实际将一个想法从空目录到运行生产服务器所需的所有资源可能是困难的。

在三个项目的过程中,《构建 Django Web 应用程序》指导您从一个空目录到创建全功能应用程序,以复制一些最受欢迎的网络应用程序的核心功能。在第一部分,您将创建自己的在线电影数据库。在第二部分,您将创建一个让用户提问和回答问题的网站。在第三部分,您将创建一个用于管理邮件列表和发送电子邮件的 Web 应用程序。所有三个项目都将最终部署到服务器上,以便您可以看到自己的想法变为现实。在开始每个项目和部署它之间,我们将涵盖重要的实用概念,如如何构建 API、保护您的项目、使用 Elasticsearch 添加搜索、使用缓存和将任务卸载到工作进程以帮助您的项目扩展。

《构建 Django Web 应用程序》适用于已经了解 Python 基础知识,但希望将自己的技能提升到更高水平的开发人员。还建议具有基本的 HTML 和 CSS 理解,因为这些语言将被提及,但不是本书的重点。

阅读完本书后,您将熟悉使用 Django 启动惊人的 Web 应用程序所需的一切。

这本书是为谁准备的

这本书是为熟悉 Python 的开发人员准备的。读者应该知道如何在 Bash shell 中运行命令。假定具有一些基本的 HTML 和 CSS 知识。最后,读者应该能够自己连接到 PostgreSQL 数据库。

充分利用本书

要充分利用本书,您应该:

  1. 对 Python 有一定了解,并已安装 Python3.6+

  2. 能够在计算机上安装 Docker 或其他新软件

  3. 知道如何从计算机连接到 Postgres 服务器

  4. 可以访问 Bash shell

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Django-2.0-Web-Applications。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个示例:“它还提供了一个create()方法,用于创建和保存实例。”

代码块设置如下:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

DATABASES = {
  'default': {
 'ENGINE': 'django.db.backends.sqlite3',
     'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),  }
}

任何命令行输入或输出都以以下方式书写:

$ pip install -r requirements.dev.txt

粗体:表示一个新术语,一个重要的词,或者屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“点击 MOVIES 将显示给我们一个电影列表。”

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

第一章:启动 MyMDB

我们将构建的第一个项目是一个基本的互联网电影数据库IMDB)克隆,名为我的电影数据库(MyMDB),使用 Django 2.0 编写,我们将使用 Docker 部署。我们的 IMDB 克隆将有以下两种类型的用户:用户和管理员。用户将能够对电影进行评分,添加电影图片,并查看电影和演员阵容。管理员将能够添加电影、演员、作家和导演。

在本章中,我们将做以下事情:

  • 创建我们的新 Django 项目 MyMDB,一个 IMDB 克隆

  • 创建一个 Django 应用程序并创建我们的第一个模型、视图和模板

  • 了解并使用我们模型中的各种字段,并在模型之间创建关系

该项目的代码可在以下网址在线获取:github.com/tomaratyn/MyMDB

最后,我们将能够在我们的项目中添加电影、人物和角色,并让用户在易于定制的 HTML 模板中查看它们。

启动我的电影数据库(MyMDB)

首先,让我们为我们的项目创建一个目录:

$ mkdir MyMDB
$ cd MyMDB

我们所有未来的命令和路径都将相对于这个项目目录。

启动项目

一个 Django 项目由多个 Django 应用程序组成。Django 应用程序可以来自许多不同的地方:

  • Django 本身(例如,django.contrib.admin,管理后台应用程序)

  • 安装 Python 包(例如,django-rest-framework,一个从 Django 模型创建 REST API 的框架)

  • 作为项目的一部分(我们将要编写的代码)

通常,一个项目会使用前面三个选项的混合。

安装 Django

我们将使用pip安装 Django,Python 的首选包管理器,并在requirements.dev.txt文件中跟踪我们安装的包:

django<2.1
psycopg2<2.8

现在,让我们安装这些包:

$ pip install -r requirements.dev.txt

创建项目

安装了 Django 后,我们有了django-admin命令行工具,可以用它来生成我们的项目:

$ django-admin startproject config
$ tree config/
config/
├── config
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

settings.py文件的父级称为config,因为我们将项目命名为config而不是mymdb。然而,让顶级目录继续被称为config是令人困惑的,所以让我们将其重命名为django(一个项目可能会包含许多不同类型的代码;再次称呼 Django 代码的父级目录为django,可以让人清楚地知道):

$ mv config django 
$ tree .
.
├── django
│   ├── config
│   │   ├── __init__.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   └── manage.py
└── requirements.dev.txt

2 directories, 6 files

让我们仔细看看其中一些文件:

  • settings.py:这是 Django 默认存储应用程序所有配置的地方。在缺少DJANGO_SETTINGS环境变量的情况下,Django 默认在这里查找设置。

  • urls.py:这是整个项目的根URLConf。你的 Web 应用程序收到的每个请求都将被路由到这个文件内匹配路径的第一个视图(或urls.py引用的文件)。

  • wsgi.pyWeb Server Gateway InterfaceWSGI)是 Python 和 Web 服务器之间的接口。你不会经常接触到这个文件,但这是你的 Web 服务器和 Python 代码知道如何相互通信的方式。我们将在第五章中引用它,使用 Docker 部署

  • manage.py:这是进行非代码更改的命令中心。无论是创建数据库迁移、运行测试,还是启动开发服务器,我们经常会使用这个文件。

请注意,缺少的是django目录不是 Python 模块。里面没有__init__.py文件,也不应该有。如果添加了一个,许多东西将会出错,因为我们希望添加的 Django 应用程序是顶级 Python 模块。

配置数据库设置

默认情况下,Django 创建一个将使用 SQLite 的项目,但这对于生产来说是不可用的,所以我们将遵循在开发和生产中使用相同数据库的最佳实践。

让我们打开django/config/settings.py并更新它以使用我们的 Postgres 服务器。找到settings.py中以DATABASES开头的行。默认情况下,它看起来像这样:

DATABASES = {
  'default': {
 'ENGINE': 'django.db.backends.sqlite3',
     'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),  }
}

要使用 Postgres,请将上述代码更改为以下代码:

DATABASES = {
    'default': {
 'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'mymdb',
        'USER': 'mymdb',
        'PASSWORD': 'development',
        'HOST': '127.0.0.1',
        'PORT': '5432',    }
}

如果您以前连接过数据库,大部分内容都会很熟悉,但让我们回顾一下:

  • DATABASES = {: 这是数据库连接信息的字典常量,并且是 Django 所必需的。您可以连接到不同数据库的多个连接,但大部分时间,您只需要一个名为default的条目。

  • 'default': {: 这是默认的数据库连接配置。您应该始终具有一组default连接设置。除非另有说明(在本书中我们不会),否则这是您将要使用的连接。

  • 'ENGINE': 'django.db.backends.postgresql ': 这告诉 Django 使用 Postgres 后端。这反过来使用psycopg2,Python 的 Postgres 库。

  • 'NAME': 'mymdb',: 您想要连接的数据库的名称。

  • ‘USER': 'mymdb',: 您的连接用户名。

  • ‘PASSWORD': 'development',: 您的数据库用户的密码。

  • ‘HOST': '127.0.0.1’,: 您要连接的数据库服务器的地址。

  • ‘PORT': '5432',: 您要连接的端口。

核心应用程序

Django 应用程序遵循模型视图模板MVT)模式;在这种模式中,我们将注意以下事项:

  • 模型负责从数据库保存和检索数据

  • 视图负责处理 HTTP 请求,启动模型上的操作,并返回 HTTP 响应

  • 模板负责响应主体的外观

在 Django 项目中,您可以拥有任意数量的应用程序。理想情况下,每个应用程序应该具有像任何其他 Python 模块一样紧密范围和自包含的功能,但在项目开始时,很难知道复杂性将出现在哪里。这就是为什么我发现从core应用程序开始很有用。然后,当我注意到特定主题周围存在复杂性集群时(比如说,在我们的项目中,如果我们在那里取得进展,演员可能会变得意外复杂),那么我们可以将其重构为自己的紧密范围的应用程序。其他时候,很明显一个站点有自包含的组件(例如,管理后端),并且很容易从多个应用程序开始。

制作核心应用程序

要创建一个新的 Django 应用程序,我们首先必须使用manage.py创建应用程序,然后将其添加到INSTALLED_APPS列表中:

$ cd django
$ python manage.py startapp core
$ ls
config      core        manage.py
$tree core
core
├─  472; __init__.py
├── admin.py
├── apps.py
├── migrations
│   └── __init__.py
├── models.py
├── tests.py
└── views.py

1 directory, 7 files

让我们更仔细地看看核心内部有什么:

  • core/__init__.py: 核心不仅是一个目录,还是一个 Python 模块。

  • admin.py: 这是我们将在其中使用内置管理后端注册我们的模型。我们将在电影管理部分进行描述。

  • apps.py: 大部分时间,您会将其保持不变。这是您将在其中放置任何在注册应用程序时需要运行的代码的地方,如果您正在制作可重用的 Django 应用程序(例如,您想要上传到 PyPi 的软件包)。

  • migrations: 这是一个带有数据库迁移的 Python 模块。数据库迁移描述了如何从一个已知状态迁移数据库到另一个状态。使用 Django,如果您添加了一个模型,您只需使用manage.py生成并运行迁移,您可以在本章后面的迁移数据库部分中看到。

  • models.py: 这是用于模型的。

  • tests.py: 这是用于测试的。

  • views.py: 这是用于视图的。

安装我们的应用程序

现在我们的核心应用程序存在了,让我们通过将其添加到settings.py文件中的已安装应用程序列表中,让 Django 意识到它。您的settings.py应该有一行看起来像这样的:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

INSTALLED_APPS是 Django 应用程序的 Python 模块的 Python 路径列表。我们已经安装了用于解决常见问题的应用程序,例如管理静态文件、会话和身份验证以及管理后端,因为 Django 的 Batteries Included 哲学。

让我们将我们的core应用程序添加到列表的顶部:

INSTALLED_APPS = [
    'core',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

添加我们的第一个模型 - 电影

现在我们可以添加我们的第一个模型,即电影。

Django 模型是从Model派生的类,具有一个或多个Fields。在数据库术语中,Model类对应于数据库表,Field类对应于列,Model的实例对应于行。使用像 Django 这样的 ORM,让我们利用 Python 和 Django 编写表达性的类,而不是在 Python 中编写我们的模型,然后再在 SQL 中编写一次。

让我们编辑django/core/models.py来添加一个Movie模型:

from django.db import models

class Movie(models.Model):
    NOT_RATED = 0
    RATED_G = 1
    RATED_PG = 2
    RATED_R = 3
    RATINGS = (
        (NOT_RATED, 'NR - Not Rated'),
        (RATED_G,
         'G - General Audiences'),
        (RATED_PG,
         'PG - Parental Guidance '
         'Suggested'),
        (RATED_R, 'R - Restricted'),
    )

    title = models.CharField(
        max_length=140)
    plot = models.TextField()
    year = models.PositiveIntegerField()
    rating = models.IntegerField(
        choices=RATINGS,
        default=NOT_RATED)
    runtime = \
        models.PositiveIntegerField()
    website = models.URLField(
        blank=True)

    def __str__(self):
        return '{} ({})'.format(
            self.title, self.year)

Movie派生自models.Model,这是所有 Django 模型的基类。接下来,有一系列描述评级的常量;我们将在查看rating字段时再看一下,但首先让我们看看其他字段:

  • title = models.CharField(max_length=140): 这将成为一个长度为 140 的varchar列。数据库通常要求varchar列的最大大小,因此 Django 也要求。

  • plot = models.TextField(): 这将成为我们数据库中的一个text列,它没有最大长度要求。这使得它更适合可以有一段(甚至一页)文本的字段。

  • year = models.PositiveIntegerField(): 这将成为一个integer列,并且 Django 将在保存之前验证该值,以确保在保存时它是0或更高。

  • rating = models.IntegerField(choices=RATINGS, default=NOT_RATED): 这是一个更复杂的字段。Django 将知道这将是一个integer列。可选参数choices(对于所有Fields都可用,不仅仅是IntegerField)接受一个值/显示对的可迭代对象(列表或元组)。对中的第一个元素是可以存储在数据库中的有效值,第二个是该值的人性化版本。Django 还将在我们的模型中添加一个名为get_rating_display()的实例方法,它将返回与存储在我们的模型中的值匹配的第二个元素。任何不匹配choices中的值的内容在保存时都将是一个ValidationErrordefault参数在创建模型时提供默认值。

  • runtime = models.PositiveIntegerField(): 这与year字段相同。

  • website = models.URLField(blank=True): 大多数数据库没有本机 URL 列类型,但数据驱动的 Web 应用程序通常需要存储它们。URLField默认情况下是一个varchar(200)字段(可以通过提供max_length参数来设置)。URLField还带有验证,检查其值是否为有效的 Web(http/https/ftp/ftps)URL。blank参数由admin应用程序用于知道是否需要值(它不影响数据库)。

我们的模型还有一个__str__(self)方法,这是一种最佳实践,有助于 Django 将模型转换为字符串。Django 在管理 UI 和我们自己的调试中都会这样做。

Django 的 ORM 自动添加了一个自增的id列,因此我们不必在所有模型上重复。这是 Django 的不要重复自己(DRY)哲学的一个简单例子。随着我们的学习,我们将看更多的例子。

迁移数据库

现在我们有了一个模型,我们需要在数据库中创建一个与之匹配的表。我们将使用 Django 为我们生成一个迁移,然后运行迁移来为我们的电影模型创建一个表。

虽然 Django 可以为我们的 Django 应用程序创建和运行迁移,但它不会为我们的 Django 项目创建数据库和数据库用户。要创建数据库和用户,我们必须使用管理员帐户连接到服务器。连接后,我们可以通过执行以下 SQL 来创建数据库和用户:

CREATE DATABASE mymdb;
CREATE USER mymdb;
GRANT ALL ON DATABASE mymdb to "mymdb";
ALTER USER mymdb PASSWORD 'development';
ALTER USER mymdb CREATEDB;

上述 SQL 语句将为我们的 Django 项目创建数据库和用户。GRANT语句确保我们的 mymdb 用户将能够访问数据库。然后,我们在mymdb用户上设置密码(确保与您的settings.py文件中的密码相同)。最后,我们授予mymdb用户创建新数据库的权限,这将在运行测试时由 Django 用于创建测试数据库。

要为我们的应用程序生成迁移,我们需要告诉manage.py文件执行以下操作:

$ cd django
$ python manage.py makemigrations core
Migrations for 'core':
  core/migrations/0001_initial.py
    - Create model Movie

迁移是我们 Django 应用程序中的一个 Python 文件,描述了如何将数据库更改为所需的状态。Django 迁移不绑定到特定的数据库系统(相同的迁移将适用于支持的数据库,除非我们添加特定于数据库的代码)。Django 生成使用 Django 的迁移 API 的迁移文件,我们不会在本书中研究它,但知道它存在是有用的。

请记住,有迁移的是应用程序而不是项目(因为有模型的是应用程序)。

接下来,我们告诉manage.py迁移我们的应用程序:

$ python manage.py migrate core 
Operations to perform:
  Apply all migrations: core
Running migrations:
  Applying core.0001_initial... OK

现在,我们的数据库中存在我们的表:

$ python manage.py dbshell
psql (9.6.1, server 9.6.3)
Type "help" for help.

mymdb=> \dt
             List of relations
 Schema |       Name        | Type  | Owner 
--------+-------------------+-------+-------
 public | core_movie        | table | mymdb
 public | django_migrations | table | mymdb
(2 rows)

mymdb=> \q

我们可以看到我们的数据库有两个表。Django 模型表的默认命名方案是<app_name>_<model_name>。我们可以看出core_moviecore应用程序的Movie模型的表。django_migrations是 Django 内部用于跟踪已应用的迁移的表。直接修改django_migrations表而不使用manage.py是一个坏主意,这将在尝试应用或回滚迁移时导致问题。

迁移命令也可以在不指定应用程序的情况下运行,在这种情况下,它将在所有应用程序上运行。让我们在没有应用程序的情况下运行migrate命令:

$ python manage.py migrate 
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, core, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying sessions.0001_initial... OK

这将创建用于跟踪用户、会话、权限和管理后端的表。

创建我们的第一个电影

与 Python 一样,Django 提供了一个交互式 REPL 来尝试一些东西。Django shell 完全连接到数据库,因此我们可以在 shell 中创建、查询、更新和删除模型:

$ cd django
$ python manage.py shell
Python 3.4.6 (default, Aug  4 2017, 15:21:32) 
[GCC 4.2.1 Compatible Apple LLVM 8.1.0 (clang-802.0.42)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from core.models import Movie
>>> sleuth = Movie.objects.create(
... title='Sleuth',
... plot='An snobbish writer who loves games'
... ' invites his wife\'s lover for a battle of wits.',
... year=1972,
... runtime=138,
... )
>>> sleuth.id
1
>>> sleuth.get_rating_display()
'NR - Not Rated'

在前面的 Django shell 会话中,请注意我们没有创建的Movie的许多属性:

  • objects是模型的默认管理器。管理器是查询模型表的接口。它还提供了一个create()方法来创建和保存实例。每个模型必须至少有一个管理器,Django 提供了一个默认管理器。通常建议创建一个自定义管理器;我们将在添加人员和模型关系部分中看到这一点。

  • id是此实例的行的主键。如前一步骤中所述,Django 会自动创建它。

  • get_rating_display()是 Django 添加的一个方法,因为rating字段给定了一个choices元组。我们在create()调用中没有为rating提供值,因为rating字段有一个default值(0)。get_rating_display()方法查找该值并返回相应的显示值。Django 将为具有choices参数的每个Field属性生成这样的方法。

接下来,让我们使用 Django Admin 应用程序创建一个管理电影的后端。

创建电影管理

能够快速生成后端 UI 让用户在项目的其余部分仍在开发中时开始构建项目的内容。这是一个很好的功能,可以帮助并行化进度并避免重复和乏味的任务(读取/更新视图共享许多功能)。提供这种功能是 Django“电池包含”哲学的另一个例子。

为了使 Django 的管理应用程序与我们的模型一起工作,我们将执行以下步骤:

  1. 注册我们的模型

  2. 创建一个可以访问后端的超级用户

  3. 运行开发服务器

  4. 在浏览器中访问后端

让我们通过编辑django/core/admin.py来注册我们的Movie模型,如下所示:

from django.contrib import admin

from core.models import Movie

admin.site.register(Movie)

现在我们的模型已注册!

现在让我们创建一个可以使用manage.py访问后端的用户:

$ cd django
$ python manage.py createsuperuser 
Username (leave blank to use 'tomaratyn'): 
Email address: tom@aratyn.nam
Password: 
Password (again): 
Superuser created successfully.

Django 附带了一个开发服务器,可以为我们的应用提供服务,但不适合生产:

$ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
September 12, 2017 - 20:31:54
Django version 1.11.5, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

还可以在浏览器中打开它,导航到http://localhost:8000/

要访问管理后端,请转到http://localhost:8000/admin

一旦使用凭据登录,我们必须管理用户和电影:

点击 MOVIES 将显示我们的电影列表:

请注意,链接的标题是我们的Movie.__str__方法的结果。点击它将为您提供一个 UI 来编辑电影:

在主管理屏幕和电影列表屏幕上,您可以找到添加新电影的链接。让我们添加一个新电影:

现在,我们的电影列表显示了所有电影:

现在我们有了一种让团队填充电影数据库的方法,让我们开始为用户编写视图。

创建 MovieList 视图

当 Django 收到请求时,它使用请求的路径和项目的URLConf来匹配请求和视图,后者返回 HTTP 响应。Django 的视图可以是函数,通常称为基于函数的视图FBVs),也可以是类,通常称为基于类的视图CBVs)。CBVs 的优势在于 Django 附带了丰富的通用视图套件,您可以对其进行子类化,以轻松(几乎是声明性地)编写视图以完成常见任务。

让我们编写一个视图来列出我们拥有的电影。打开django/core/views.py并将其更改为以下内容:

from django.views.generic import ListView

from core.models import Movie

class MovieList(ListView):
    model = Movie

ListView至少需要一个model属性。它将查询该模型的所有行,将其传递给模板,并返回渲染后的模板响应。它还提供了许多我们可以使用的钩子来替换默认行为,这些都有完整的文档记录。

ListView如何知道如何查询Movie中的所有对象?为此,我们需要讨论管理器和QuerySet类。每个模型都有一个默认管理器。管理器类主要用于通过提供方法(例如all())来查询对象,返回QuerySetQuerySet类是 Django 对数据库查询的表示。QuerySet有许多方法,包括filter()(例如SELECT语句中的WHERE子句)来限制结果。QuerySet类的一个很好的特性是它是惰性的;直到我们尝试从QuerySet中获取模型时,它才会被评估。另一个很好的特性是filter()等方法采用查找表达式,可以是字段名称或跨关系模型。我们将在整个项目中都这样做。

所有管理器类都有一个all()方法,应返回一个未经过滤的Queryset,相当于编写SELECT * FROM core_movie;

那么,ListView如何知道它必须查询Movie中的所有对象?ListView检查它是否有model属性,如果有,它知道Model类具有默认管理器,带有all()方法,它会调用该方法。ListView还为我们提供了放置模板的约定,如下所示:<app_name>/<model_name>_list.html

添加我们的第一个模板 - movie_list.html

Django 附带了自己的模板语言,称为Django 模板语言。Django 还可以使用其他模板语言(例如 Jinja2),但大多数 Django 项目发现使用 Django 模板语言是高效和方便的。

在我们的settings.py文件中生成的默认配置中,Django 模板语言配置为使用APP_DIRS,这意味着每个 Django 应用程序都可以有一个templates目录,该目录将被搜索以找到模板。这可以用来覆盖其他应用程序使用的模板,而无需修改第三方应用程序本身。

让我们在django/core/templates/core/movie_list.html中创建我们的第一个模板:

<!DOCTYPE html>
<html>
  <body>
    <ul>
      {% for movie in object_list %}
        <li>{{ movie }}</li>
      {% empty %}
        <li>
          No movies yet.
        </li>
      {% endfor %}
    </ul>
    <p>
      Using https? 
      {{ request.is_secure|yesno }}
    </p>
  </body>
</html>

Django 模板是标准的 HTML(或者您希望使用的任何文本格式),其中包含变量(例如我们的示例中的object_list)和标签(例如我们的示例中的for)。变量将通过用{{ }}括起来来评估为字符串。过滤器可以用来在打印之前帮助格式化或修改变量(例如yesno)。我们还可以创建自定义标签和过滤器。

Django 文档中提供了完整的过滤器和标签列表(docs.djangoproject.com/en/2.0/ref/templates/builtins/)。

Django 模板语言在settings.pyTEMPLATES变量中进行配置。DjangoTemplates后端可以使用很多OPTIONS。在开发中,添加'string_if_invalid': 'INVALID_VALUE',可能会有所帮助。每当 Django 无法将模板中的变量匹配到变量或标签时,它将打印出INVALID_VALUE,这样更容易捕捉拼写错误。请记住,不要在生产中使用此设置。完整的选项列表可以在 Django 的文档中找到(docs.djangoproject.com/en/dev/topics/templates/#django.template.backends.django.DjangoTemplates)。

最后一步将是将我们的视图连接到一个URLConf

使用 URLConf 将请求路由到我们的视图

现在我们有了模型、视图和模板,我们需要告诉 Django 应该将哪些请求路由到我们的MovieList视图使用 URLConf。每个新项目都有一个由 Django 创建的根 URLConf(在我们的情况下是django/config/urls.py文件)。Django 开发人员已经形成了每个应用程序都有自己的 URLConf 的最佳实践。然后,项目的根 URLConf 将使用include()函数包含每个应用程序的 URLConf。

让我们通过创建一个django/core/urls.py文件并使用以下代码来为我们的core应用程序创建一个 URLConf:

from django.urls import path

from . import views

app_name = 'core'
urlpatterns = [
    path('movies',
         views.MovieList.as_view(),
         name='MovieList'),
]

在其最简单的形式中,URLConf 是一个带有urlpatterns属性的模块,其中包含一系列pathpath由描述路径的字符串和可调用对象组成。CBV 不是可调用的,因此基本的View类有一个静态的as_view()方法来返回一个可调用对象。FBV 可以直接作为回调传递(不需要()运算符,这会执行它们)。

每个path()都应该被命名,这是一个有用的最佳实践,当我们需要在模板中引用该路径时。由于一个 URLConf 可以被另一个 URLConf 包含,我们可能不知道我们的视图的完整路径。Django 提供了reverse()函数和url模板标签,可以从名称转到视图的完整路径。

app_name变量设置了这个URLConf所属的应用程序。这样,我们可以引用一个命名的path,而不会让 Django 混淆其他应用程序具有相同名称的path(例如,index是一个非常常见的名称,所以我们可以说appA:indexappB:index来区分它们)。

最后,让我们通过将django/config/urls.py更改为以下内容来将我们的URLConf连接到根URLConf

from django.urls import path, include
from django.contrib import admin

import core.urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include(
        core.urls, namespace='core')),
]

这个文件看起来很像我们之前的URLConf文件,只是我们的path()对象不是取一个视图,而是include()函数的结果。include()函数让我们可以用一个路径前缀整个URLConf并给它一个自定义的命名空间。

命名空间让我们区分path名称,就像app_name属性一样,但不需要修改应用程序(例如,第三方应用程序)。

您可能会想为什么我们使用include()而 Django 管理网站使用propertyinclude()admin.site.urls都返回格式类似的 3 元组。但是,您应该使用include(),而不是记住 3 元组的每个部分应该具有什么。

运行开发服务器

Django 现在知道如何将请求路由到我们的 View,View 知道需要显示哪些模型以及要呈现哪个模板。我们可以告诉manage.py启动我们的开发服务器并查看我们的结果:

$ cd django
$ python manage.py runserver

在我们的浏览器中,转到http://127.0.0.1:8000/movies

干得好!我们制作了我们的第一个页面!

在这一部分,我们创建了我们的第一个模型,生成并运行了它的迁移,并创建了一个视图和模板,以便用户可以浏览它。

现在,让我们为每部电影添加一个页面。

单独的电影页面

现在我们有了项目布局,我们可以更快地移动。我们已经在跟踪每部电影的信息。让我们创建一个视图来显示这些信息。

要添加电影详细信息,我们需要做以下事情:

  1. 创建MovieDetail视图

  2. 创建movie_detail.html模板

  3. 在我们的URLConf中引用MovieDetail视图

创建 MovieDetail 视图

就像 Django 为我们提供了一个ListView类来执行列出模型的所有常见任务一样,Django 还提供了一个DetailView类,我们可以子类化以创建显示单个Model详细信息的视图。

让我们在django/core/views.py中创建我们的视图:

from django.views.generic import (
    ListView, DetailView,
)
from core.models import Movie

class MovieDetail(DetailView):
    model = Movie

class MovieList(ListView):
    model = Movie

DetailView要求path()对象在path字符串中包含pkslug,以便DetailView可以将该值传递给QuerySet以查询特定的模型实例。slug是一个短的、URL 友好的标签,通常在内容丰富的网站中使用,因为它对 SEO 友好。

创建 movie_detail.html 模板

现在我们有了 View,让我们制作我们的模板。

Django 的模板语言支持模板继承,这意味着您可以编写一个包含网站外观和感觉的模板,并标记其他模板将覆盖的block部分。这使我们能够创建整个网站的外观和感觉,而无需编辑每个模板。让我们使用这个功能创建一个具有 MyMDB 品牌和外观的基本模板,然后添加一个从基本模板继承的电影详细信息模板。

基本模板不应该与特定的应用程序绑定,因此让我们创建一个通用的模板目录:

$ mkdir django/templates

Django 还不知道如何检查我们的templates目录,因此我们需要更新settings.py文件中的配置。找到以TEMPLATES开头的行,并更改配置以在DIRS列表中列出我们的templates目录:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates'),
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            # omittted for brevity
        },
    },
]

我们唯一做的改变是将我们的新templates目录添加到DIRS键下的列表中。我们避免使用 Python 的os.path.join()函数和已配置的BASE_DIR来将路径硬编码到我们的templates目录。BASE_DIR在运行时设置为项目的路径。我们不需要添加django/core/templates,因为APP_DIRS设置告诉 Django 检查每个应用程序的templates目录。

虽然settings.py是一个非常方便的 Python 文件,我们可以在其中使用os.path.join和所有 Python 的功能,但要小心不要太聪明。settings.py需要易于阅读和理解。没有什么比不得不调试你的settings.py更糟糕的了。

让我们在django/templates/base.html中创建一个基本模板,其中有一个主列和侧边栏:

<!DOCTYPE html>
<html lang="en" >
<head >
  <meta charset="UTF-8" >
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1, shrink-to-fit=no"
  >
  <link
    href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css"
    integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M"
    rel="stylesheet"
    crossorigin="anonymous"
  >
  <title >
    {% block title %}MyMDB{% endblock %}
  </title>
  <style>
    .mymdb-masthead {
      background-color: #EEEEEE;
      margin-bottom: 1em;
    }
  </style>

</head >
<body >
<div class="mymdb-masthead">
  <div class="container">
    <nav class="nav">
      <div class="navbar-brand">MyMDB</div>
      <a
        class="nav-link"
        href="{% url 'core:MovieList' %}"
      >
        Movies
      </a>
    </nav>
  </div>
</div>

<div class="container">
  <div class="row">
    <div class="col-sm-8 mymdb-main">
     {% block main %}{% endblock %}
    </div>
    <div
        class="col-sm-3 offset-sm-1 mymdb-sidebar"
    >
      {% block sidebar %}{% endblock %}
    </div>
  </div>
</div>

</body >
</html >

这个 HTML 的大部分实际上是 bootstrap(HTML/CSS 框架)样板,但我们有一些新的 Django 标签:

  • {% block title %}MyMDB{% endblock %}:这创建了一个其他模板可以替换的块。如果未替换该块,则将使用父模板中的内容。

  • href="{% url 'core:MovieList' %}"url标签将为命名的path生成 URL 路径。URL 名称应该被引用为<app_namespace>:<name>;在我们的情况下,core是核心应用程序的命名空间(在django/core/urls.py中),而MovieListMovieList视图的 URL 的名称。

这样我们就可以在django/core/templates/core/movie_detail.html中创建一个简单的模板:

{% extends 'base.html' %}

{% block title %}
  {{ object.title }} - {{ block.super }}
{% endblock %}

{% block main %}
<h1>{{ object }}</h1>
<p class="lead">
{{ object.plot }}
</p>
{% endblock %}

{% block sidebar %}
<div>
This movie is rated:
  <span class="badge badge-primary">
  {{ object.get_rating_display }}
  </span>
</div>
{% endblock %}

这个模板的 HTML 要少得多,因为base.html已经有了。MovieDetail.html所要做的就是为base.html定义的块提供值。让我们来看看一些新标签:

  • {% extends 'base.html' %}:如果一个模板想要扩展另一个模板,第一行必须是一个extends标签。Django 将寻找基本模板(它可以扩展另一个模板)并首先执行它,然后替换块。一个扩展另一个的模板不能在block之外有内容,因为不清楚将内容放在哪里。

  • {{ object.title }} - {{ block.super }}:我们在title模板block中引用block.superblock.super返回基本模板中title模板block的内容。

  • {{ object.get_rating_display }}:Django 模板语言不使用()来执行方法,只需通过名称引用它即可执行该方法。

将 MovieDetail 添加到 core.urls.py

最后,我们将MovieDetail视图添加到core/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('movies',
         views.MovieList.as_view(),
         name='MovieList'),
    path('movie/<int:pk>',
         views.MovieDetail.as_view(),
         name='MovieDetail'),
]

MovieDetailMovieListpath()调用几乎看起来一样,只是MovieDetail字符串有一个命名参数。path路由字符串可以包括尖括号,给参数一个名称(例如,<pk>),甚至定义参数的内容必须符合的类型(例如,<int:pk>只匹配解析为int的值)。这些命名部分被 Django 捕获并按名称传递给视图。DetailView期望一个pk(或slug)参数,并使用它从数据库中获取正确的行。

让我们使用python manage.py runserver来启动dev服务器,看看我们的新模板是什么样子的:

快速回顾本节

在本节中,我们创建了一个新视图MovieDetail,学习了模板继承,以及如何将参数从 URL 路径传递给我们的视图。

接下来,我们将为我们的MovieList视图添加分页,以防止每次查询整个数据库。

分页和将电影列表链接到电影详情

在这一部分,我们将更新我们的电影列表,为每部电影提供一个链接,并进行分页,以防止我们的整个数据库被倾倒到一个页面上。

更新 MovieList.html 以扩展 base.html

我们原来的MovieList.html是一个相当简陋的事情。让我们使用我们的base.html模板和它提供的 bootstrap CSS 来更新它,使它看起来更好看:

{% extends 'base.html' %}

{% block title %}
All The Movies
{% endblock %}

{% block main %}
<ul>
  {% for movie in object_list %}
    <li>
      <a href="{% url 'core:MovieDetail' pk=movie.id %}">
        {{ movie }}
      </a>
    </li>
  {% endfor %}
  </ul>
{% endblock %}

我们还看到url标签与命名参数pk一起使用,因为MovieDetail URL 需要一个pk参数。如果没有提供参数,那么 Django 在渲染时会引发NoReverseMatch异常,导致500错误。

让我们来看看它是什么样子的:

设置订单

我们当前视图的另一个问题是它没有排序。如果数据库返回的是无序查询,那么分页就无法帮助导航。而且,每次用户更改页面时,内容都不一致,因为数据库可能会返回一个不同顺序的结果集。我们需要我们的查询有一致的顺序。

对我们的模型进行排序也可以让开发人员的生活更轻松。无论是使用调试器、编写测试,还是运行 shell,确保我们的模型以一致的顺序返回可以使故障排除变得更简单。

Django 模型可以选择具有一个名为Meta的内部类,它让我们指定有关模型的信息。让我们添加一个带有ordering属性的Meta类:

class Movie(models.Model):
   # constants and fields omitted for brevity 

    class Meta:
        ordering = ('-year', 'title')

    def __str__(self):
        return '{} ({})'.format(
            self.title, self.year)

ordering接受一个列表或元组,通常是字段名称的字符串,可选地以-字符为前缀,表示降序。('-year', 'title')相当于 SQL 子句ORDER BY year DESC, title

ordering添加到模型的Meta类中意味着来自模型管理器的QuerySets将被排序。

添加分页

现在我们的电影总是以相同的方式排序,让我们添加分页。Django 的ListView已经内置了对分页的支持,所以我们只需要利用它。分页由控制要显示的页面的GET参数page控制。

让我们在我们的main模板block底部添加分页:

{% block main %}
 <ul >
    {% for movie in object_list %}
      <li >
        <a href="{% url 'core:MovieDetail' pk=movie.id %}" >
          {{ movie }}
        </a >
      </li >
    {% endfor %}
  </ul >
  {% if is_paginated %}
    <nav >
      <ul class="pagination" >
        <li class="page-item" >
          <a
            href="{% url 'core:MovieList' %}?page=1"
            class="page-link"
          >
            First
          </a >
        </li >
        {% if page_obj.has_previous %}
          <li class="page-item" >
            <a
              href="{% url 'core:MovieList' %}?page={{ page_obj.previous_page_number }}"
              class="page-link"
            >
              {{ page_obj.previous_page_number }}
            </a >
          </li >
        {% endif %}
        <li class="page-item active" >
          <a
            href="{% url 'core:MovieList' %}?page={{ page_obj.number }}"
            class="page-link"
          >
            {{ page_obj.number }}
          </a >
        </li >
        {% if page_obj.has_next %}
          <li class="page-item" >
            <a
              href="{% url 'core:MovieList' %}?page={{ page_obj.next_page_number }}"
              class="page-link"
            >
              {{ page_obj.next_page_number }}
            </a >
          </li >
        {% endif %}
        <li class="page-item" >
          <a
              href="{% url 'core:MovieList' %}?page=last"
              class="page-link"
          >
            Last
          </a >
        </li >
      </ul >
    </nav >
  {% endif %}
{% endblock %}

让我们看一下我们的MovieList模板的一些重要点:

  • page_objPage类型,知道有关此结果页面的信息。我们使用它来检查是否有下一页/上一页,使用has_next()/has_previous()(在 Django 模板语言中,我们不需要在()中放置(),但has_next()是一个方法,而不是属性)。我们还使用它来获取next_page_number()/previous_page_number()。请注意,在检索下一页/上一页数字之前使用has_*()方法检查其存在性非常重要。如果在检索时它们不存在,Page会抛出EmptyPage异常。

  • object_list仍然可用并保存正确的值。即使page_obj封装了此页面的结果在page_obj.object_list中,ListView也方便地确保我们可以继续使用object_list,而我们的模板不会中断。

我们现在有分页功能了!

404-当事物丢失时

现在我们有一些视图,如果在 URL 中给出错误的值(错误的pk将破坏MovieDetail;错误的page将破坏MovieList),它们将无法正常运行;让我们通过处理404错误来解决这个问题。Django 在根 URLConf 中提供了一个钩子,让我们可以使用自定义视图来处理404错误(也适用于403400500,都遵循相同的命名方案)。在您的根urls.py文件中,添加一个名为handler404的变量,其值是指向您自定义视图的 Python 路径的字符串。

但是,我们可以继续使用默认的404处理程序视图,并只编写一个自定义模板。让我们在django/templates/404.html中添加一个404模板:

{% extends "base.html" %}

{% block title %}
Not Found
{% endblock %}

{% block main %}
<h1>Not Found</h1>
<p>Sorry that reel has gone missing.</p>
{% endblock %}

即使另一个应用程序抛出404错误,也将使用此模板。

目前,如果您有一个未使用的 URL,例如http://localhost:8000/not-a-real-page,您将看不到我们的自定义 404 模板,因为 Django 的settings.py中的DEBUG设置为True。要使我们的 404 模板可见,我们需要更改settings.py中的DEBUGALLOWED_HOSTS设置:

DEBUG = False

ALLOWED_HOSTS = [
    'localhost',
    '127.0.0.1'
]

ALLOWED_HOSTS是一个设置,限制 Django 将响应的 HTTP 请求中的HOST值。如果DEBUGFalse,并且HOST不匹配ALLOWED_HOSTS值,则 Django 将返回400错误(您可以根据前面的代码自定义此错误的视图和模板)。这是一项保护我们的安全功能,将在我们的安全章节中更多地讨论。

现在我们的项目已配置好,让我们运行 Django 开发服务器:

$ cd django
$ python manage.py runserver

运行时,我们可以使用我们的网络浏览器打开localhost:8000/not-a-real-page。我们的结果应该是这样的:

测试我们的视图和模板

由于我们现在在MoveList模板中有一些逻辑,让我们写一些测试。我们将在第八章 测试 Answerly中更多地讨论测试。但是,基础知识很简单,遵循常见的 XUnit 模式,即TestCase类包含进行断言的测试方法。

对于 Django 的TestRunner来找到一个测试,它必须在已安装应用的tests模块中。现在,这意味着tests.py,但是,最终,您可能希望切换到一个目录 Python 模块(在这种情况下,为了让TestRunner找到它们,为您的测试文件名加上test前缀)。

让我们添加一个执行以下功能的测试:

  • 如果有超过 10 部电影,那么分页控件应该在模板中呈现

  • 如果有超过 10 部电影,而我们没有提供page GET参数,请考虑以下事项:

  • page_is_last上下文变量应该是False

  • page_is_first上下文变量应该是True

  • 分页中的第一项应该被标记为活动状态

以下是我们的tests.py文件:

from django.test import TestCase
from django.test.client import \
    RequestFactory
from django.urls.base import reverse

from core.models import Movie
from core.views import MovieList

class MovieListPaginationTestCase(TestCase):

    ACTIVE_PAGINATION_HTML = """
    <li class="page-item active">
      <a href="{}?page={}" class="page-link">{}</a>
    </li>
    """

    def setUp(self):
        for n in range(15):
            Movie.objects.create(
                title='Title {}'.format(n),
                year=1990 + n,
                runtime=100,
            )

    def testFirstPage(self):
        movie_list_path = reverse('core:MovieList')
        request = RequestFactory().get(path=movie_list_path)
        response = MovieList.as_view()(request)
        self.assertEqual(200, response.status_code)
        self.assertTrue(response.context_data['is_paginated'])
        self.assertInHTML(
            self.ACTIVE_PAGINATION_HTML.format(
                movie_list_path, 1, 1),
            response.rendered_content)

让我们看一些有趣的地方:

  • class MovieListPaginationTestCase(TestCase): TestCase是所有 Django 测试的基类。它内置了许多便利功能,包括许多方便的断言方法。

  • def setUp(self): 像大多数 XUnit 测试框架一样,Django 的TestCase类提供了一个在每个测试之前运行的setUp()钩子。如果需要,还可以使用tearDown()钩子。在每个测试之间清理数据库,因此我们不需要担心删除任何我们添加的模型。

  • def testFirstPage(self):: 如果方法的名称以test开头,那么它就是一个测试方法。

  • movie_list_path = reverse('core:MovieList'): reverse()之前提到过,它是url Django 模板标签的 Python 等价物。它将解析名称为路径。

  • request = RequestFactory().get(path=movie_list_path): RequestFactory是一个方便的工厂,用于创建虚拟的 HTTP 请求。RequestFactory具有创建GETPOSTPUT请求的便利方法,这些方法以动词命名(例如,get()用于GET请求)。在我们的情况下,提供的path对象并不重要,但其他视图可能希望检查请求的路径。

  • self.assertEqual(200, response.status_code): 这断言两个参数是否相等。检查响应的status_code以检查成功或失败(200是成功的状态代码——在浏览网页时从不会看到的代码)。

  • self.assertTrue(response.context_data['is_paginated']):这断言该参数评估为Trueresponse公开了在渲染模板中使用的上下文。这使得查找错误变得更容易,因为您可以快速检查在渲染中使用的实际值。

  • self.assertInHTML(: assertInHTML是 Django 提供的许多便利方法之一,作为其一揽子哲学的一部分。给定一个有效的 HTML 字符串needle和有效的 HTML 字符串haystack,它将断言needle是否在haystack中。这两个字符串需要是有效的 HTML,因为 Django 将解析它们并检查一个是否在另一个中。您不需要担心间距或属性/类的顺序。当您尝试确保模板正常工作时,这是一个非常方便的断言。

要运行测试,我们可以使用manage.py

$ cd django
$ python manage.py test 
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.035s

OK
Destroying test database for alias 'default'...

最后,我们可以确信我们已经正确地实现了分页。

添加人物和模型关系

在本节中,我们将在项目中为模型添加关系。人物与电影的关系可以创建一个复杂的数据模型。同一个人可以是演员、作家和导演(例如,The Apostle(1997)由 Robert Duvall 编写、导演和主演)。即使忽略了工作人员和制作团队并简化了一些,数据模型将涉及使用ForiengKey字段的一对多关系,使用ManyToManyField的多对多关系,以及使用ManyToManyField中的through类添加关于多对多关系的额外信息的类。

在本节中,我们将逐步执行以下操作:

  1. 创建一个Person模型

  2. MoviePerson添加一个ForeignKey字段以跟踪导演

  3. MoviePerson添加一个ManyToManyField来跟踪编剧

  4. 添加一个带有through类(Actor)的ManyToManyField来跟踪谁在电影中扮演了什么角色

  5. 创建迁移

  6. 将导演、编剧和演员添加到电影详情模板

  7. 为列表添加一个PersonDetail视图,指示一个人导演了哪些电影,写了哪些电影,以及在哪些电影中表演了

添加具有关系的模型

首先,我们需要一个Person类来描述和存储参与电影的人:

class Person(models.Model):
    first_name = models.CharField(
        max_length=140)
    last_name = models.CharField(
        max_length=140)
    born = models.DateField()
    died = models.DateField(null=True,
                            blank=True)

    class Meta:
        ordering = (
            'last_name', 'first_name')

    def __str__(self):
        if self.died:
            return '{}, {} ({}-{})'.format(
                self.last_name,
                self.first_name,
                self.born,
                self.died)
        return '{}, {} ({})'.format(
                self.last_name,
                self.first_name,
                self.born)

Person中,我们还看到了一个新字段(DateField)和字段的一个新参数(null)。

DateField用于跟踪基于日期的数据,使用数据库上的适当列类型(Postgres 上的date)和 Python 中的datetime.date。Django 还提供了DateTimeField来存储日期和时间。

所有字段都支持null参数(默认为False),它指示列是否应该接受NULL SQL 值(在 Python 中表示为None)。我们将died标记为支持null,以便我们可以记录人是活着还是死了。然后,在__str__()方法中,如果某人是活着的或死了,我们打印出不同的字符串表示。

现在我们有了Person模型,它可以与Movies有各种关系。

不同类型的关系字段

Django 的 ORM 支持映射模型之间的关系的字段,包括一对多、多对多和带有中间模型的多对多。

当两个模型有一对多的关系时,我们使用ForeignKey字段,它将在两个表之间创建一个带有外键FK)约束的列(假设有数据库支持)。在没有ForeignKey字段的模型中,Django 将自动添加RelatedManager对象作为实例属性。RelatedManager类使得在关系中查询对象更容易。我们将在以下部分看一些例子。

当两个模型有多对多的关系时,它们中的一个(但不是两者)可以得到ManyToManyField();Django 将在另一侧为你创建一个RelatedManager。正如你可能知道的,关系数据库实际上不能在两个表之间有多对多的关系。相反,关系数据库需要一个桥接表,其中包含到每个相关表的外键。假设我们不想添加任何描述关系的属性,Django 将自动为我们创建和管理这个桥接表。

有时,我们想要额外的字段来描述多对多的关系(例如,它何时开始或结束);为此,我们可以提供一个带有through模型的ManyToManyField(有时在 UML/OO 中称为关联类)。这个模型将对关系的每一侧都有一个ForeignKey和我们想要的任何额外字段。

在我们添加导演、编剧和演员到我们的Movie模型时,我们将为每一个创建一个例子。

导演 - 外键

在我们的模型中,我们将说每部电影可以有一个导演,但每个导演可以导演很多电影。让我们使用ForiengKey字段来为我们的电影添加一个导演:

class Movie(models.Model):
   # constants, methods, Meta class and other fields omitted for brevity.
    director = models.ForeignKey(
        to='Person',
        on_delete=models.SET_NULL,
        related_name='directed',
        null=True,
        blank=True)

让我们逐行查看我们的新字段:

  • to='Person':Django 的所有关系字段都可以接受字符串引用以及对相关模型的引用。这个参数是必需的。

  • on_delete=models.SET_NULL: Django 需要指示在引用的模型(实例/行)被删除时该怎么做。SET_NULL将把所有由已删除的Person导演的Movie模型实例的director字段设置为NULL。如果我们想要级联删除,我们将使用models.CASCADE对象。

  • related_name='directed':这是一个可选参数,表示另一个模型上的RelatedManager实例的名称(它让我们查询Person导演的所有Movie模型实例)。如果没有提供related_name,那么Person将得到一个名为movie_set的属性(遵循<具有 FK 的模型>_set模式)。在我们的情况下,我们将在MoviePerson之间有多个不同的关系(编剧,导演和演员),所以movie_set将变得模糊不清,我们必须提供一个related_name

这也是我们第一次向现有模型添加字段。在这样做时,我们必须要么添加null=True要么提供一个default值。如果不这样做,那么迁移将强制我们这样做。这是因为 Django 必须假设在迁移运行时表中存在现有行(即使没有),当数据库添加新列时,它需要知道应该插入现有行的内容。在director字段的情况下,我们可以接受它有时可能是NULL

我们现在已经向Movie添加了一个字段,并向Person实例添加了一个名为directed的新属性(类型为RelatedManager)。RelatedManager是一个非常有用的类,它类似于模型的默认 Manager,但自动管理两个模型之间的关系。

让我们看看person.directed.create()并将其与Movie.objects.create()进行比较。这两种方法都将创建一个新的Movie,但person.directed.create()将确保新的Movieperson作为其directorRelatedManager还提供了addremove方法,以便我们可以通过调用person.directed.add(movie)Movie添加到Persondirected集合中。还有一个类似的remove()方法,但是从关系中删除一个模型。

Writers - ManyToManyField

两个模型也可以有多对多的关系,例如,一个人可以写很多电影,一个电影也可以由很多人写。接下来,我们将在我们的Movie模型中添加一个writers字段:

class Movie(models.Model):
   # constants, methods, Meta class and other fields omitted for brevity.
    writers = models.ManyToManyField(
        to='Person',
        related_name='writing_credits',
        blank=True)

ManyToManyField建立了一个多对多的关系,并且像RelatedManager一样,允许用户查询和创建模型。我们再次使用related_name来避免给Person一个movie_set属性,而是给它一个writing_credits属性,它将是一个RelatedManager

ManyToManyField的情况下,关系的两侧都有RelatedManager,因此person.writing_credits.add(movie)的效果与写movie.writers.add(person)相同。

Role - 通过类的 ManyToManyField

我们将看一个关系字段的最后一个例子,当我们想要使用一个中间模型来描述两个其他模型之间的多对多关系时使用。Django 允许我们通过创建一个模型来描述两个多对多关系模型之间的连接表来实现这一点。

在我们的例子中,我们将通过RoleMoviePerson之间创建一个多对多关系,它将有一个name属性:

class Movie(models.Model):
   # constants, methods, Meta class and other fields omitted for brevity.
    actors = models.ManyToManyField(
        to='Person',
        through='Role',
        related_name='acting_credits',
        blank=True)

class Role(models.Model):
    movie = models.ForeignKey(Movie, on_delete=models.DO_NOTHING)
    person = models.ForeignKey(Person, on_delete=models.DO_NOTHING)
    name = models.CharField(max_length=140)

    def __str__(self):
        return "{} {} {}".format(self.movie_id, self.person_id, self.name)

    class Meta:
        unique_together = ('movie',
                           'person',
                           'name')

这看起来像前面的ManyToManyField,只是我们有一个to(引用Person)参数和一个through(引用Role)参数。

Role模型看起来很像一个连接表的设计;它对多对多关系的每一侧都有一个ForeignKey。它还有一个额外的字段叫做name来描述角色。

Role还对其进行了唯一约束。它要求moviepersonbilling一起是唯一的;在RoleMeta类上设置unique_together属性将防止重复数据。

这种使用ManyToManyField将创建四个新的RelatedManager实例:

  • movie.actors将是Person的相关管理器

  • person.acting_credits将是Movie的相关管理器

  • movie.role_set将是Role的相关管理器

  • person.role_set将是Role的相关管理器

我们可以使用任何管理器来查询模型,但只能使用role_set管理器来创建模型或修改关系,因为存在中间类。如果尝试运行movie.actors.add(person),Django 将抛出IntegrityError异常,因为没有办法填写Role.name的值。但是,您可以编写movie.role_set.add(person=person, name='Hamlet')

添加迁移

现在,我们可以为我们的新模型生成一个迁移:

$ python manage.py makemigrations core
Migrations for 'core':
  core/migrations/0002_auto_20170926_1650.py
    - Create model Person
    - Create model Role
    - Change Meta options on movie
    - Add field movie to role
    - Add field person to role
    - Add field actors to movie
    - Add field director to movie
    - Add field writers to movie
    - Alter unique_together for role (1 constraint(s))

然后,我们可以运行我们的迁移,以应用这些更改:

$ python manage.py migrate core
Operations to perform:
  Apply all migrations: core
Running migrations:
  Applying core.0002_auto_20170926_1651... OK

接下来,让我们让我们的电影页面链接到电影中的人物。

创建一个 PersonView 并更新 MovieList

让我们添加一个PersonDetail视图,我们的movie_detail.html模板可以链接到。为了创建我们的视图,我们将经历一个四步过程:

  1. 创建一个管理器来限制数据库查询的数量

  2. 创建我们的视图

  3. 创建我们的模板

  4. 创建引用我们视图的 URL

创建自定义管理器-PersonManager

我们的PersonDetail视图将列出一个Person在其中扮演、编写或导演的所有电影。在我们的模板中,我们将打印出每个角色中每部电影的名称(以及扮演角色的Role.name)。为了避免向数据库发送大量查询,我们将为我们的模型创建新的管理器,这些管理器将返回更智能的QuerySet

在 Django 中,每当我们跨越关系访问属性时,Django 将查询数据库以获取相关项目(例如在每个相关Role上循环时person.role_set.all(),对于每个相关Role)。对于出演N部电影的Person,这将导致N次数据库查询。我们可以使用prefetch_related()方法避免这种情况(稍后我们将看到select_related()方法)。使用prefetch_related()方法,Django 将在单个附加查询中查询单个关系的所有相关数据。但是,如果我们最终没有使用预取的数据,查询它将浪费时间和内存。

让我们创建一个PersonManager,其中包含一个新的方法all_with_prefetch_movies(),并将其设置为Person的默认管理器:

class PersonManager(models.Manager):
    def all_with_prefetch_movies(self):
        qs = self.get_queryset()
        return qs.prefetch_related(
            'directed',
            'writing_credits',
            'role_set__movie')

class Person(models.Model):
    # fields omitted for brevity

    objects = PersonManager()

    class Meta:
        ordering = (
            'last_name', 'first_name')

    def __str__(self):
        # body omitted for brevity

我们的PersonManager仍将提供与默认相同的所有方法,因为PersonManager继承自models.Manager。我们还定义了一个新方法,该方法使用get_queryset()获取QuerySet,并告诉它预取相关模型。QuerySets是惰性的,因此直到查询集被评估(例如通过迭代、转换为布尔值、切片或通过if语句进行评估)之前,与数据库的通信都不会发生。DetailView在使用get()获取模型时才会评估查询。

prefetch_related()方法接受一个或多个lookups,在初始查询完成后,它会自动查询这些相关模型。当您访问与您的QuerySet中的模型相关的模型时,Django 不必查询它,因为您已经在QuerySet中预取了它。

查询是 Django QuerySet用来表示模型中的字段或RelatedManager的方式。查询甚至可以跨越关系,通过用两个下划线分隔关系字段(或RelatedManager)和相关模型的字段来实现:

Movie.objects.all().filter(actors__last_name='Freeman', actors__first_name='Morgan')

上述调用将返回一个QuerySet,其中摩根·弗里曼曾经是演员的所有Movie模型实例。

在我们的PersonManager中,我们告诉 Django 预取Person执导、编写和扮演的所有电影,以及预取角色本身。使用all_with_prefetch_movies()方法将导致查询数量保持恒定,无论Person的作品有多么丰富。

创建一个 PersonDetail 视图和模板

现在我们可以在django/core/views.py中编写一个非常简单的视图:

class PersonDetail(DetailView):
    queryset = Person.objects.all_with_prefetch_movies()

这个DetailView不同的地方在于我们没有为它提供一个model属性。相反,我们从我们的PersonManager类中给它一个QuerySet对象。当DetailView使用QuerySetfilter()get()方法来检索模型实例时,DetailView将从模型实例的类名中派生模板的名称,就像我们在视图上提供了模型类属性一样。

现在,让我们在django/core/templates/core/person_detail.html中创建我们的模板:

{% extends 'base.html' %}

{% block title %}
  {{ object.first_name }}
  {{ object.last_name }}
{% endblock %}

{% block main %}

  <h1>{{ object }}</h1>
  <h2>Actor</h2>
  <ul >
    {% for role in object.role_set.all %}
      <li >
        <a href="{% url 'core:MovieDetail' role.movie.id %}" >
          {{ role.movie }}
        </a >:
        {{ role.name }}
      </li >
    {% endfor %}
  </ul >
  <h2>Writer</h2>
  <ul >
    {% for movie in object.writing_credits.all %}
      <li >
        <a href="{% url 'core:MovieDetail' movie.id %}" >
          {{ movie }}
        </a >
      </li >
    {% endfor %}
  </ul >
  <h2>Director</h2>
  <ul >
    {% for movie in object.directed.all %}
      <li >
        <a href="{% url 'core:MovieDetail' movie.id %}" >
          {{ movie }}
        </a >
      </li >
    {% endfor %}
  </ul >

{% endblock %}

我们的模板不需要做任何特殊的事情来利用我们的预取。

接下来,我们应该给MovieDetail视图提供与我们的PersonDetail视图相同的好处。

创建 MovieManager

让我们从django/core/models.py中创建一个MovieManager开始:

class MovieManager(models.Manager):

    def all_with_related_persons(self):
        qs = self.get_queryset()
        qs = qs.select_related(
            'director')
        qs = qs.prefetch_related(
            'writers', 'actors')
        return qs

class Movie(models.Model):
    # constants and fields omitted for brevity
    objects = MovieManager()

    class Meta:
        ordering = ('-year', 'title')

    def __str__(self):
         # method body omitted for brevity

MovieManager引入了另一个新方法,称为select_related()select_related()方法与prefetch_related()方法非常相似,但当关系只导致一个相关模型时(例如,使用ForeignKey字段),它会被使用。select_related()方法通过使用JOIN SQL 查询来在一次查询中检索两个模型。当关系可能导致多个模型时(例如,ManyToManyField的任一侧或RelatedManager属性),使用prefetch_related()

现在,我们可以更新我们的MovieDetail视图,以使用查询集而不是直接使用模型:

class MovieDetail(DetailView):
    queryset = (
        Movie.objects
            .all_with_related_persons())

视图渲染完全相同,但在需要相关的Person模型实例时,它不必每次查询数据库,因为它们都已经被预取。

本节的快速回顾

在这一部分,我们创建了Person模型,并在MoviePerson模型之间建立了各种关系。我们使用ForeignKey字段类创建了一对多的关系,使用ManyToManyField类创建了多对多的关系,并使用了一个中介(或关联)类,通过为ManyToManyField提供一个through模型来为多对多关系添加额外的信息。我们还创建了一个PersonDetail视图来显示Person模型实例,并使用自定义模型管理器来控制 Django 发送到数据库的查询数量。

总结

在本章中,我们创建了我们的 Django 项目,并启动了我们的core Django 应用程序。我们看到了如何使用 Django 的模型-视图-模板方法来创建易于理解的代码。我们在模型附近创建了集中的数据库逻辑,视图中的分页,以及遵循 Django 最佳实践的模板中的 HTML,即fat models, thin views,dumb templates

现在我们准备添加用户,他们可以注册并投票给他们最喜欢的电影。

第二章:将用户添加到 MyMDB

在上一章中,我们启动了我们的项目并创建了我们的core应用程序和我们的core模型(MoviePerson)。在本章中,我们将在此基础上做以下事情:

  • 让用户注册、登录和退出

  • 让已登录用户对电影进行投票

  • 根据投票为每部电影评分

  • 使用投票来推荐前 10 部电影。

让我们从管理用户开始这一章。

创建user应用程序

在本节中,您将创建一个名为user的新 Django 应用程序,将其注册到您的项目中,并使其管理用户。

在第一章 构建 MyMDB 的开头,您了解到 Django 项目由许多 Django 应用程序组成(例如我们现有的core应用程序)。Django 应用程序应提供明确定义和紧密范围的行为。将用户管理添加到我们的core应用程序中违反了这一原则。让一个 Django 应用程序承担太多责任会使测试和重用变得更加困难。例如,我们将在本书中的整个过程中重用我们在这个user Django 应用程序中编写的代码。

创建一个新的 Django 应用程序

在我们创建core应用程序时所做的一样,我们将使用manage.py来生成我们的user应用程序:

$ cd django
$ python manage.py startapp user
$ cd user
$ ls
__init__.py     admin.py        apps.py         migrations      models.py       tests.py        views.py

接下来,我们将通过编辑我们的django/config/settings.py文件并更新INSTALLED_APPS属性来将其注册到我们的 Django 项目中:

INSTALLED_APPS = [
    'user',  # must come before admin
    'core',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

出于我们将在登录和退出部分讨论的原因,我们需要将user放在admin应用程序之前。通常,将我们的应用程序放在内置应用程序之上是一个好主意。

我们的user应用程序现在是我们项目的一部分。通常,我们现在会继续为我们的应用程序创建和定义模型。但是,由于 Django 内置的auth应用程序,我们已经有了一个可以使用的用户模型。

如果我们想使用自定义用户模型,那么我们可以通过更新settings.py并将AUTH_USER_MODEL设置为模型的字符串 python 路径来注册它(例如,AUTH_USER_MODEL=myuserapp.models.MyUserModel)。

接下来,我们将创建我们的用户注册视图。

创建用户注册视图

我们的RegisterView类将负责让用户注册我们的网站。如果它收到一个GET请求,那么它将向用户显示UserCreationFrom;如果它收到一个POST请求,它将验证数据并创建用户。UserCreationFormauth应用程序提供,并提供了一种收集和验证注册用户所需数据的方式;此外,如果数据有效,它还能保存一个新的用户模型。

让我们将我们的视图添加到django/user/views.py中:

from django.contrib.auth.forms import (
    UserCreationForm,
)
from django.urls import (
    reverse_lazy,
)
from django.views.generic import (
    CreateView,
)

class RegisterView(CreateView):
    template_name = 'user/register.html'
    form_class = UserCreationForm
    success_url = reverse_lazy(
        'core:MovieList')

让我们逐行查看我们的代码:

  • class RegisterView(CreateView)::我们的视图扩展了CreateView,因此不必定义如何处理GETPOST请求,我们将在接下来的步骤中讨论。

  • template_name = 'user/register.html':这是一个我们将创建的模板。它的上下文将与我们以前看到的有些不同;它不会有objectobject_list变量,但会有一个form变量,它是form_class属性中设置的类的实例。

  • form_class = UserCreationForm:这是这个CreateView应该使用的表单类。更简单的模型可以只说model = MyModel,但是用户稍微复杂一些,因为密码需要输入两次然后进行哈希处理。我们将在第三章 海报、头像和安全 中讨论 Django 如何存储密码。

  • success_url = reverse_lazy('core:MovieList'):当模型创建成功时,这是您需要重定向到的 URL。这实际上是一个可选参数;如果模型有一个名为model.get_absolute_url()的方法,那么将使用该方法,我们就不需要提供success_url

CreateView的行为分布在许多基类和 mixin 中,它们通过方法相互作用,作为我们可以重写以改变行为的挂钩。让我们来看看一些最关键的点。

如果CreateView收到GET请求,它将呈现表单的模板。 CreateView的祖先之一是FormMixin,它重写了get_context_data()来调用get_form()并将表单实例添加到我们模板的上下文中。 渲染的模板作为响应的主体由render_to_response返回。

如果CreateView收到POST请求,它还将使用get_form()来获取表单实例。 表单将被绑定到请求中的POST数据。 绑定的表单可以验证其绑定的数据。 CreateView然后将调用form.is_valid(),并根据需要调用form_valid()form_invalid()form_valid()将调用form.save()(将数据保存到数据库)然后返回一个 302 响应,将浏览器重定向到success_urlform_invalid()方法将使用包含错误消息的表单重新呈现模板,供用户修复并重新提交。

我们还第一次看到了reverse_lazy()。 它是reverse()的延迟版本。 延迟函数是返回值直到使用时才解析的函数。 我们不能使用reverse(),因为视图类在构建完整的 URLConfs 集时进行评估,所以如果我们需要在视图的级别使用reverse(),我们必须使用reverse_lazy()。 值直到视图返回其第一个响应才会解析。

接下来,让我们为我们的视图创建模板。

创建 RegisterView 模板

在编写带有 Django 表单的模板时,我们必须记住 Django 不提供<form><button type='submit>标签,只提供表单主体的内容。 这让我们有可能在同一个<form>中包含多个 Django 表单。 有了这个想法,让我们将我们的模板添加到django/user/templates/user/register.html中:

{% extends "base.html" %}

{% block main %}
  <h1>Register for MyMDB</h1>
  <form method="post">
    {{ form.as_p}}
    {% csrf_token %}
    <button
        type="submit"
        class="btn btn-primary">
      Register
    </button>
  </form>
{% endblock %}

与我们之前的模板一样,我们扩展base.html并将我们的代码放在现有block之一中(在这种情况下是main)。 让我们更仔细地看看表单是如何呈现的。

当表单呈现时,它分为两部分,首先是一个可选的<ul class='errorlist'>标签,用于一般错误消息(如果有的话),然后每个字段分为四个基本部分:

  • 一个带有字段名称的<label>标签

  • 一个<ul class="errorlist">标签,显示用户先前表单提交的错误;只有在该字段有错误时才会呈现

  • 一个<input>(或<select>)标签来接受输入

  • 一个<span class="helptext">标签,用于字段的帮助文本

Form带有以下三个实用方法来呈现表单:

  • as_table(): 每个字段都包裹在一个<tr>标签中,标签中包含一个<th>标签和一个包裹在<td>标签中的小部件。 不提供包含的<table>标签。

  • as_ul: 整个字段(标签和帮助文本小部件)都包裹在一个<li>标签中。 不提供包含的<ul>标签。

  • as_p: 整个字段(标签和帮助文本小部件)都包裹在一个<p>标签中。

对于相同的表单,不提供包含<table><ul>标签,也不提供<form>标签,以便在必要时更容易一起输出多个表单。

如果您想对表单呈现进行精细的控制,Form实例是可迭代的,在每次迭代中产生一个Field,或者可以按名称查找为form["fieldName"]

在我们的示例中,我们使用as_p()方法,因为我们不需要精细的布局控制。

这个模板也是我们第一次看到csrf_token标签。 CSRF 是 Web 应用程序中常见的漏洞,我们将在第三章中更多地讨论它,海报、头像和安全性。 Django 自动检查所有POSTPUT请求是否有有效的csrfmiddlewaretoken和标头。 缺少这个的请求甚至不会到达视图,而是会得到一个403 Forbidden的响应。

现在我们有了模板,让我们在我们的 URLConf 中为我们的视图添加一个path()对象。

添加到 RegisterView 的路径

我们的user应用程序没有urls.py文件,所以我们需要创建django/user/urls.py文件:

from django.urls import path

from user import views

app_name = 'user'
urlpatterns = [
    path('register',
         views.RegisterView.as_view(),
         name='register'),
]

接下来,我们需要在django/config/urls.py的根 URLConf 中include()此 URLConf:

from django.urls import path, include
from django.contrib import admin

import core.urls
import user.urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('user/', include(
        user.urls, namespace='user')),
    path('', include(
        core.urls, namespace='core')),
]

由于 URLConf 只会搜索直到找到第一个匹配的path,因此我们总是希望将没有前缀或最广泛的 URLConfs 的path放在最后,以免意外阻止其他视图。

登录和登出

Django 的auth应用程序提供了用于登录和注销的视图。将此添加到我们的项目将是一个两步过程:

  1. user URLConf 中注册视图

  2. 为视图添加模板

更新用户 URLConf

Django 的auth应用程序提供了许多视图,以帮助简化用户管理和身份验证,包括登录/注销、更改密码和重置忘记的密码。一个功能齐全的生产应用程序应该为用户提供所有三个功能。在我们的情况下,我们将限制自己只提供登录和注销。

让我们更新django/user/urls.py以使用auth的登录和注销视图:

from django.urls import path
from django.contrib.auth import views as auth_views

from user import views

app_name = 'user'
urlpatterns = [
    path('register',
         views.RegisterView.as_view(),
         name='register'),
    path('login/',
         auth_views.LoginView.as_view(),
         name='login'),
    path('logout/',
         auth_views.LogoutView.as_view(),
         name='logout'),
]

如果您提供了登录/注销、更改密码和重置密码,则可以使用auth的 URLConf,如下面的代码片段所示:

from django.contrib.auth import urls
app_name = 'user'
urlpatterns = [
    path('', include(urls)),
]

现在,让我们添加模板。

创建一个 LoginView 模板

首先,在django/user/templates/registration/login.html中为登录页面添加模板:

{% extends "base.html" %}

{% block title %}
Login - {{ block.super }}
{% endblock %}

{% block main %}
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button
        class="btn btn-primary">
      Log In
    </button>
  </form>
{% endblock %}

前面的代码看起来与user/register.html非常相似。

但是,当用户登录时应该发生什么?

成功的登录重定向

RegisterView中,我们能够指定成功后将用户重定向到何处,因为我们创建了视图。LoginView类将按照以下步骤决定将用户重定向到何处:

  1. 如果POST参数next是一个有效的 URL,并指向托管此应用程序的服务器,则使用POST参数nextpath()名称不可用。

  2. 如果next是一个有效的 URL,并指向托管此应用程序的服务器,则使用GET参数nextpath()名称不可用。

  3. LOGIN_REDIRECT_URL设置默认为'/accounts/profile/'path()名称可用

在我们的情况下,我们希望将所有用户重定向到电影列表,所以让我们更新django/config/settings.py以设置LOGIN_REDIRECT_URL

LOGIN_REDIRECT_URL = 'core:MovieList'

但是,如果有情况需要将用户重定向到特定页面,我们可以使用next参数将其专门重定向到特定页面。例如,如果用户尝试在登录之前执行操作,我们将他们所在的页面传递给LoginView作为next参数,以便在登录后将他们重定向回所在的页面。

现在,当用户登录时,他们将被重定向到我们的电影列表视图。接下来,让我们为注销视图创建一个模板。

创建一个 LogoutView 模板

LogoutView类的行为有些奇怪。如果它收到一个GET请求,它将注销用户,然后尝试呈现registration/logged_out.htmlGET请求修改用户状态是不寻常的,因此值得记住这个视图有点不同。

LogoutView类还有另一个问题。如果您没有提供registration/logged_out.html模板,并且已安装admin应用程序,则 Django 可能会使用admin的模板,因为admin应用程序确实有该模板(退出admin应用程序,您会看到它)。

Django 将模板名称解析为文件的方式是一个三步过程,一旦找到文件,就会停止,如下所示:

  1. Django 遍历settings.TEMPLATESDIRS列表中的目录。

  2. 如果APP_DIRSTrue,则它将遍历INSTALLED_APPS中列出的应用程序,直到找到匹配项。如果adminINSTALLED_APPS列表中出现在user之前,那么它将首先匹配。如果user在前面,user将首先匹配。

  3. 引发TemplateDoesNotExist异常。

这就是为什么我们把user放在已安装应用程序列表的第一位,并添加了一个警告未来开发人员不要改变顺序的注释。

我们现在已经完成了我们的user应用程序。让我们回顾一下我们取得了什么成就。

快速回顾本节

我们创建了一个user应用来封装用户管理。在我们的user应用中,我们利用了 Django 的auth应用提供的许多功能,包括UserCreationFormLoginViewLogoutView类。我们还了解了 Django 提供的一些新的通用视图,并结合UserCreationForm类使用CreateView来创建RegisterView类。

现在我们有了用户,让我们允许他们对我们的电影进行投票。

让用户对电影进行投票

像 IMDB 这样的社区网站的一部分的乐趣就是能够对我们喜欢和讨厌的电影进行投票。在 MyMDB 中,用户将能够为电影投票,要么是,要么是。一部电影将有一个分数,即的数量减去的数量。

让我们从投票的最重要部分开始:Vote模型。

创建 Vote 模型

在 MyMDB 中,每个用户可以对每部电影投一次票。投票可以是正面的——或者是负面的—

让我们更新我们的django/core/models.py文件来拥有我们的Vote模型:

class Vote(models.Model):
    UP = 1
    DOWN = -1
    VALUE_CHOICES = (
        (UP, "",),
        (DOWN, "",),
    )

    value = models.SmallIntegerField(
        choices=VALUE_CHOICES,
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE
    )
    movie = models.ForeignKey(
        Movie,
        on_delete=models.CASCADE,
    )
    voted_on = models.DateTimeField(
        auto_now=True
    )

    class Meta:
        unique_together = ('user', 'movie')

这个模型有以下四个字段:

  • value,必须是1-1

  • user是一个ForeignKey,它通过settings.AUTH_USER_MODEL引用User模型。Django 建议您永远不要直接引用django.contrib.auth.models.User,而是使用settings.AUTH_USER_MODELdjango.contrib.auth.get_user_model()

  • movie是一个引用Movie模型的ForeignKey

  • voted_on是一个带有auto_now启用的DateTimeFieldauto_now参数使模型在每次保存模型时更新字段为当前日期时间。

unique_together属性的Meta在表上创建了一个唯一约束。唯一约束将防止两行具有相同的usermovie值,强制执行我们每个用户每部电影一次投票的规则。

让我们为我们的模型创建一个迁移,使用manage.py

$ python manage.py makemigrations core
Migrations for 'core':
  core/migrations/0003_auto_20171003_1955.py
    - Create model Vote
    - Alter field rating on movie
    - Add field movie to vote
    - Add field user to vote
    - Alter unique_together for vote (1 constraint(s))

然后,让我们运行我们的迁移:

$ python manage.py migrate core
Operations to perform:
  Apply all migrations: core
Running migrations:
  Applying core.0003_auto_20171003_1955... OK

现在我们已经设置好了我们的模型和表,让我们创建一个表单来验证投票。

创建 VoteForm

Django 的表单 API 非常强大,让我们可以创建几乎任何类型的表单。如果我们想创建一个任意的表单,我们可以创建一个扩展django.forms.Form的类,并向其中添加我们想要的字段。然而,如果我们想构建一个代表模型的表单,Django 为我们提供了一个快捷方式,即django.forms.ModelForm

我们想要的表单类型取决于表单将被放置的位置以及它将如何被使用。在我们的情况下,我们想要一个可以放在MovieDetail页面上的表单,并让它给用户以下两个单选按钮:

让我们来看看可能的最简单的VoteForm

from django import forms

from core.models import Vote

class VoteForm(forms.ModelForm):
    class Meta:
        model = Vote
        fields = (
            'value', 'user', 'movie',)

Django 将使用valueusermovie字段从Vote模型生成一个表单。usermovie将是使用<select>下拉列表选择正确值的ModelChoiceField,而value是一个使用<select>下拉小部件的ChoiceField,这不是我们默认想要的。

VoteForm将需要usermovie。由于我们将使用VoteForm来保存新的投票,我们不能消除这些字段。然而,让用户代表其他用户投票将会创建一个漏洞。让我们自定义我们的表单来防止这种情况发生:

from django import forms
from django.contrib.auth import get_user_model

from core.models import Vote, Movie

class VoteForm(forms.ModelForm):

    user = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=get_user_model().
            objects.all(),
        disabled=True,
    )
    movie = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=Movie.objects.all(),
        disabled=True
    )
    value = forms.ChoiceField(
        label='Vote',
        widget=forms.RadioSelect,
        choices=Vote.VALUE_CHOICES,
    )

    class Meta:
        model = Vote
        fields = (
            'value', 'user', 'movie',)

在前面的表单中,我们已经自定义了字段。

让我们仔细看一下user字段:

  • user = forms.ModelChoiceField(: ModelChoiceField接受另一个模型作为该字段的值。通过提供有效选项的QuerySet实例来验证模型的选择。

  • queryset=get_user_model().objects.all(),:定义此字段的有效选择的QuerySet。在我们的情况下,任何用户都可以投票。

  • widget=forms.HiddenInput,: HiddenInput小部件呈现为<input type='hidden'>HTML 元素,这意味着用户不会被任何 UI 分散注意力。

  • disabled=True,: disabled参数告诉表单忽略此字段的任何提供的数据,只使用代码中最初提供的值。这可以防止用户代表其他用户投票。

movie字段与user基本相同,但queryset属性查询Movie模型实例。

值字段以不同的方式进行了定制:

  • value = forms.ChoiceField(: ChoiceField用于表示可以从有限集合中具有单个值的字段。默认情况下,它由下拉列表小部件表示。

  • label='Vote',: label属性让我们自定义此字段使用的标签。虽然value在我们的代码中有意义,但我们希望用户认为他们的投票是![](https://gitee.com/OpenDocCN/freelearn-python-web-zh/raw/master/docs/bd-dj20-webapp/img/05b14743-dedd-4122-97df-cc15869422be.png)/![](https://gitee.com/OpenDocCN/freelearn-python-web-zh/raw/master/docs/bd-dj20-webapp/img/73102249-cbaf-442e-a8f8-7ba208bb4348.png)

  • widget=forms.RadioSelect,: 下拉列表隐藏选项,直到用户点击下拉列表。但我们的值是我们希望始终可见的有效行动呼叫。使用RadioSelect小部件,Django 将每个选择呈现为<input type='radio'>标签,并带有适当的<label>标签和name值,以便更容易进行投票。

  • choices=Vote.VALUE_CHOICES,: ChoiceField必须告知有效选择;方便的是,它使用与模型字段的choices参数相同的格式,因此我们可以重用模型中使用的Vote.VALUE_CHOICES元组。

我们新定制的表单将显示为标签vote和两个单选按钮。

现在我们有了表单,让我们将投票添加到MovieDetail视图,并创建知道如何处理投票的视图。

创建投票视图

在这一部分,我们将更新MovieDetail视图,让用户投票并记录投票到数据库中。为了处理用户的投票,我们将创建以下两个视图:

  • CreateVote,这将是一个CreateView,如果用户尚未为电影投票

  • UpdateVote,这将是一个UpdateView,如果用户已经投票但正在更改他们的投票

让我们从更新MovieDetail开始,为电影提供投票的 UI。

将 VoteForm 添加到 MovieDetail

我们的MovieDetail.get_context_data方法现在会更加复杂。它将需要获取用户对电影的投票,实例化表单,并知道将投票提交到哪个 URL(create_voteupdate_vote)。

我们首先需要一种方法来检查用户模型是否对给定的Movie模型实例有相关的Vote模型实例。为此,我们将创建一个带有自定义方法的VoteManager类。我们的方法将具有特殊行为 - 如果没有匹配的Vote模型实例,它将返回一个未保存的空白Vote对象。这将使我们更容易使用正确的movieuser值实例化我们的VoteForm

这是我们的新VoteManager

class VoteManager(models.Manager):

    def get_vote_or_unsaved_blank_vote(self, movie, user):
        try:
            return Vote.objects.get(
                movie=movie,
                user=user)
        except Vote.DoesNotExist:
            return Vote(
                movie=movie,
                user=user)

class Vote(models.Model):
    # constants and field omitted

    objects = VoteManager()

    class Meta:
        unique_together = ('user', 'movie')

VoteManager与我们以前的Manager非常相似。

我们以前没有遇到的一件事是使用构造函数实例化模型(例如,Vote(movie=movie, user=user))而不是使用其管理器的create()方法。使用构造函数在内存中创建一个新模型,但在数据库中创建。未保存的模型本身是完全可用的(通常可用所有方法和管理器方法),但除了依赖关系的任何内容。未保存的模型没有id,因此在调用其save()方法保存之前,无法使用RelatedManagerQuerySet查找它。

现在我们已经拥有了MovieDetail所需的一切,让我们来更新它:

class MovieDetail(DetailView):
    queryset = (
        Movie.objects
           .all_with_related_persons())

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        if self.request.user.is_authenticated:
            vote = Vote.objects.get_vote_or_unsaved_blank_vote(
                movie=self.object,
                user=self.request.user
            )
                    if vote.id:
                vote_form_url = reverse(
                    'core:UpdateVote',
                    kwargs={
                        'movie_id': vote.movie.id,
                        'pk': vote.id})
            else:
                vote_form_url = (
                    reverse(
                        'core:CreateVote',
                        kwargs={
                            'movie_id': self.object.id}
                    )
                )
            vote_form = VoteForm(instance=vote)
            ctx['vote_form'] = vote_form
            ctx['vote_form_url'] = \
                vote_form_url
        return ctx

我们在上述代码中引入了两个新元素,self.request和使用实例化表单。

视图通过它们的request属性访问它们正在处理的请求。此外,Request有一个user属性,它让我们访问发出请求的用户。我们使用这个来检查用户是否已经验证,因为只有已验证的用户才能投票。

ModelForms可以使用它们所代表的模型的实例进行实例化。当我们使用一个实例实例化ModelForm并渲染它时,字段将具有实例的值。一个常见任务的一个很好的快捷方式是在这个表单中显示这个模型的值。

我们还将引用两个我们还没有创建的path;我们马上就会创建。首先,让我们通过更新movie_detail.html模板的侧边栏块来完成我们的MovieDetail更新:

{% block sidebar %}
 {# rating div omitted #}
  <div>
    {% if vote_form %}
      <form
          method="post"
          action="{{ vote_form_url }}" >
        {% csrf_token %}
        {{ vote_form.as_p }}
        <button
            class="btn btn-primary" >
          Vote
        </button >
      </form >
    {% else %}
      <p >Log in to vote for this
        movie</p >
    {% endif %}
  </div >
{% endblock %}

在设计这个过程中,我们再次遵循模板应该具有尽可能少的逻辑的原则。

接下来,让我们添加我们的CreateVote视图。

创建CreateVote视图

CreateVote视图将负责使用VoteForm验证投票数据,然后创建正确的Vote模型实例。然而,我们不会为投票创建一个模板。如果有问题,我们将把用户重定向到MovieDetail视图。

这是我们应该在django/core/views.py文件中拥有的CreateVote视图:

from django.contrib.auth.mixins import (
    LoginRequiredMixin, )
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import (
    CreateView, )

from core.forms import VoteForm

class CreateVote(LoginRequiredMixin, CreateView):
    form_class = VoteForm

    def get_initial(self):
        initial = super().get_initial()
        initial['user'] = self.request.user.id
        initial['movie'] = self.kwargs[
            'movie_id']
        return initial

    def get_success_url(self):
        movie_id = self.object.movie.id
        return reverse(
            'core:MovieDetail',
            kwargs={
                'pk': movie_id})

    def render_to_response(self, context, **response_kwargs):
        movie_id = context['object'].id
        movie_detail_url = reverse(
            'core:MovieDetail',
            kwargs={'pk': movie_id})
        return redirect(
            to=movie_detail_url)

在前面的代码中,我们引入了四个与RegisterView类不同的新概念——get_initial()render_to_response()redirect()LoginRequiredMixin。它们如下:

  • get_initial()用于在表单从请求中获取data值之前,使用initial值预填充表单。这对于VoteForm很重要,因为我们已经禁用了movieuserForm会忽略分配给禁用字段的data。即使用户在表单中发送了不同的movie值或user值,它也会被禁用字段忽略,而我们的initial值将被使用。

  • render_to_response()CreateView调用以返回一个包含渲染模板的响应给客户端。在我们的情况下,我们不会返回一个包含模板的响应,而是一个 HTTP 重定向到MovieDetail。这种方法有一个严重的缺点——我们会丢失与表单相关的任何错误。然而,由于我们的用户只有两种输入选择,我们也无法提供太多错误消息。

  • redirect()来自 Django 的django.shortcuts包。它提供了常见操作的快捷方式,包括创建一个 HTTP 重定向响应到给定的 URL。

  • LoginRequiredMixin是一个可以添加到任何View中的 mixin,它将检查请求是否由已验证用户发出。如果用户没有登录,他们将被重定向到登录页面。

Django 的默认登录页面设置为/accounts/profile/,所以让我们通过编辑settings.py文件并添加一个新的设置来改变这一点:

LOGIN_REDIRECT_URL = 'user:login'

现在我们有一个视图,它将创建一个Vote模型实例,并在成功或失败时将用户重定向回相关的MovieDetail视图。

接下来,让我们添加一个视图,让用户更新他们的Vote模型实例。

创建UpdateVote视图

UpdateVote视图要简单得多,因为UpdateView(就像DetailView)负责查找投票,尽管我们仍然必须关注Vote的篡改。

让我们更新我们的django/core/views.py文件:

from django.contrib.auth.mixins import (
    LoginRequiredMixin, )
from django.core.exceptions import (
    PermissionDenied)
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import (
    UpdateView, )

from core.forms import VoteForm

class UpdateVote(LoginRequiredMixin, UpdateView):
    form_class = VoteForm
    queryset = Vote.objects.all()

    def get_object(self, queryset=None):
        vote = super().get_object(
            queryset)
        user = self.request.user
        if vote.user != user:
            raise PermissionDenied(
                'cannot change another '
                'users vote')
        return vote

    def get_success_url(self):
        movie_id = self.object.movie.id
        return reverse(
            'core:MovieDetail',
            kwargs={'pk': movie_id})

    def render_to_response(self, context, **response_kwargs):
        movie_id = context['object'].id
        movie_detail_url = reverse(
            'core:MovieDetail',
            kwargs={'pk': movie_id})
        return redirect(
            to=movie_detail_url)

我们的UpdateVote视图在get_object()方法中检查检索到的Vote是否是已登录用户在其中的投票。我们添加了这个检查来防止投票篡改。我们的用户界面不会让用户错误地这样做。如果Vote不是由已登录用户投出的,那么UpdateVote会抛出一个PermissionDenied异常,Django 会处理并返回一个403 Forbidden响应。

最后一步将是在core URLConf 中注册我们的新视图。

core/urls.py中添加视图

我们现在创建了两个新视图,但是,和往常一样,除非它们在 URLConf 中列出,否则用户无法访问它们。让我们编辑core/urls.py

urlpatterns = [
    # previous paths omitted
    path('movie/<int:movie_id>/vote',
         views.CreateVote.as_view(),
         name='CreateVote'),
    path('movie/<int:movie_id>/vote/<int:pk>',
         views.UpdateVote.as_view(),
         name='UpdateVote'),
]

本节的快速回顾

在本节中,我们看到了如何构建基本和高度定制的表单来接受和验证用户输入。我们还讨论了一些简化处理表单常见任务的内置视图。

接下来,我们将展示如何开始使用我们的用户、投票来对每部电影进行排名并提供一个前 10 名的列表。

计算电影得分

在这一部分,我们将使用 Django 的聚合查询 API 来计算每部电影的得分。Django 通过将功能内置到其QuerySet对象中,使编写与数据库无关的聚合查询变得容易。

让我们首先添加一个计算MovieManager得分的方法。

使用 MovieManager 来计算电影得分

我们的MovieManager类负责构建与Movie相关的QuerySet对象。我们现在需要一个新的方法,该方法检索电影(理想情况下仍与相关人员相关)并根据其收到的投票总和标记每部电影的得分(我们可以简单地对所有的1-1求和)。

让我们看看如何使用 Django 的QuerySet.annotate() API 来做到这一点:

from django.db.models.aggregates import (
    Sum
)

class MovieManager(models.Manager):

    def all_with_related_persons(self):
        qs = self.get_queryset()
        qs = qs.select_related(
            'director')
        qs = qs.prefetch_related(
            'writers', 'actors')
        return qs

    def all_with_related_persons_and_score(self):
        qs = self.all_with_related_persons()
        qs = qs.annotate(score=Sum('vote__value'))
        return qs

all_with_related_persons_and_score中,我们调用all_with_related_persons并获得一个我们可以进一步使用annotate()调用修改的QuerySet

annotate将我们的常规 SQL 查询转换为聚合查询,将提供的聚合操作的结果添加到一个名为score的新属性中。Django 将大多数常见的 SQL 聚合函数抽象为类表示,包括SumCountAverage(以及更多)。

新的score属性可用于我们从QuerySetget()出来的任何实例,以及我们想要在我们的新QuerySet上调用的任何方法(例如,qs.filter(score__gt=5)将返回一个具有score属性大于 5 的电影的QuerySet)。

我们的新方法仍然返回一个懒惰的QuerySet,这意味着我们的下一步是更新MovieDetail及其模板。

更新 MovieDetail 和模板

现在我们可以查询带有得分的电影,让我们更改MovieDetail使用的QuerySet

 class MovieDetail(DetailView):
    queryset = Movie.objects.all_with_related_persons_and_score() 
    def get_context_data(self, **kwargs):
        # body omitted for brevity

现在,当MovieDetail在其查询集上使用get()时,该Movie将具有一个得分属性。让我们在我们的movie_detail.html模板中使用它:

{% block sidebar %}
  {# movie rating div omitted #}
  <div >
    <h2 >
      Score: {{ object.score|default_if_none:"TBD" }}
    </h2 >
  </div>
  {# voting form div omitted #}
{% endblock %}

我们可以安全地引用score属性,因为MovieDetailQuerySet。然而,我们不能保证得分不会是None(例如,如果Movie没有投票)。为了防止空白得分,我们使用default_if_none过滤器来提供一个要打印的值。

我们现在有一个可以计算所有电影得分的MovieManager方法,但是当您在MovieDetail中使用它时,这意味着它只会为正在显示的Movie计算得分。

总结

在本章中,我们向我们的系统添加了用户,让他们注册、登录(和退出登录),并对我们的电影进行投票。我们学会了如何使用聚合查询来高效地计算数据库中这些投票的结果。

接下来,我们将让用户上传与我们的MoviePeople模型相关的图片,并讨论安全考虑。

第三章:海报、头像和安全性

电影是一种视觉媒体,所以电影数据库至少应该有图片。让用户上传文件可能会带来很大的安全隐患;因此,在本章中,我们将一起讨论这两个主题。

在本章中,我们将做以下事情:

  • 为每部电影添加一个允许用户上传图像的文件上传功能

  • 检查开放式 Web 应用安全项目OWASP)风险前 10 名清单

我们将在进行文件上传时检查安全性的影响。此外,我们将看看 Django 在哪些方面可以帮助我们,在哪些方面我们必须做出谨慎的设计决策。

让我们从向 MyMDB 添加文件上传开始。

将文件上传到我们的应用程序

在本节中,我们将创建一个模型,用于表示和管理用户上传到我们网站的文件;然后,我们将构建一个表单和视图来验证和处理这些上传。

配置文件上传设置

在我们开始实现文件上传之前,我们需要了解文件上传取决于一些必须在生产和开发中不同的设置。这些设置会影响文件的存储和提供方式。

Django 有两组文件设置:STATIC_*MEDIA_*静态文件是我们项目的一部分,由我们开发的文件(例如 CSS 和 JavaScript)。媒体文件是用户上传到我们系统的文件。媒体文件不应该受信任,绝对应该被执行。

我们需要在我们的django/conf/settings.py中设置两个新的设置:

MEDIA_URL = '/uploaded/'
MEDIA_ROOT = os.path.join(BASE_DIR, '../media_root')

MEDIA_URL是将提供上传文件的 URL。在开发中,这个值并不太重要,只要它不与我们的视图之一的 URL 冲突即可。在生产中,上传的文件应该从与提供我们应用程序的域名(而不是子域名)不同的域名提供。一个用户的浏览器如果被欺骗执行了来自与我们应用程序相同的域名(或子域名)的文件,那么它将信任该文件的 cookie(包括用户的会话 ID)。所有浏览器的默认策略称为同源策略。我们将在第五章 使用 Docker 部署中再次讨论这个问题。

MEDIA_ROOT是 Django 应该保存代码的目录路径。我们希望确保这个目录不在我们的代码目录下,这样它就不会意外地被检入版本控制,也不会意外地被授予任何慷慨的权限(例如执行权限),我们授予我们的代码库。

在生产中,我们还有其他设置需要配置,比如限制请求体的大小,但这些将作为第五章 使用 Docker 部署的一部分来完成。

接下来,让我们创建media_root目录:

$ mkdir media_root
$ ls
django                 media_root              requirements.dev.txt

太好了!接下来,让我们创建我们的MovieImage模型。

创建 MovieImage 模型

我们的MovieImage模型将使用一个名为ImageField的新字段来保存文件,并尝试验证文件是否为图像。尽管ImageField确实尝试验证字段,但这并不足以阻止一个恶意用户制作一个故意恶意的文件(但会帮助一个意外点击了.zip而不是.png的用户)。Django 使用Pillow库来进行此验证;因此,让我们将Pillow添加到我们的要求文件requirements.dev.txt中:

Pillow<4.4.0

然后,使用pip安装我们的依赖项:

$ pip install -r requirements.dev.txt

现在,我们可以创建我们的模型:

from uuid import uuid4

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

def movie_directory_path_with_uuid(
        instance, filename):
    return '{}/{}'.format(
        instance.movie_id, uuid4())

class MovieImage(models.Model):
    image = models.ImageField(
        upload_to=movie_directory_path_with_uuid)
    uploaded = models.DateTimeField(
        auto_now_add=True)
    movie = models.ForeignKey(
        'Movie', on_delete=models.CASCADE)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE)

ImageFieldFileField的一个专门版本,它使用Pillow来确认文件是否为图像。ImageFieldFileField与 Django 的文件存储 API 一起工作,该 API 提供了一种存储和检索文件以及读写文件的方式。默认情况下,Django 使用FileSystemStorage,它实现了存储 API 以在本地文件系统上存储数据。这对于开发来说已经足够了,但我们将在第五章中探讨替代方案,使用 Docker 部署

我们使用了ImageFieldupload_to参数来指定一个函数来生成上传文件的名称。我们不希望用户能够指定系统中文件的名称,因为他们可能会选择滥用我们用户的信任并让我们看起来很糟糕的名称。我们使用一个函数来将给定电影的所有图片存储在同一个目录中,并使用uuid4为每个文件生成一个通用唯一名称(这也避免了名称冲突和处理文件互相覆盖)。

我们还记录了谁上传了文件,这样如果我们发现了一个坏文件,我们就有线索可以找到其他坏文件。

现在让我们进行迁移并应用它:

$ python manage.py makemigrations core
Migrations for 'core':
  core/migrations/0004_movieimage.py
    - Create model MovieImage
$ python manage.py migrate core
Operations to perform:
  Apply all migrations: core
Running migrations:
  Applying core.0004_movieimage... OK

接下来,让我们为我们的MovieImage模型构建一个表单,并在我们的MovieDetail视图中使用它。

创建和使用 MovieImageForm

我们的表单将与我们的VoteForm非常相似,它将隐藏和禁用movieuser字段,这些字段对于我们的模型是必要的,但是从客户端信任是危险的。让我们将它添加到django/core/forms.py中:

from django import forms

from core.models import MovieImage

class MovieImageForm(forms.ModelForm):

    movie = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=Movie.objects.all(),
        disabled=True
    )

    user = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=get_user_model().
            objects.all(),
        disabled=True,
    )

    class Meta:
        model = MovieImage
        fields = ('image', 'user', 'movie')

我们不会用自定义字段或小部件覆盖image字段,因为ModelForm类将自动提供正确的<input type="file">

现在,我们可以在MovieDetail视图中使用它:

from django.views.generic import DetailView

from core.forms import (VoteForm, 
    MovieImageForm,)
from core.models import Movie

class MovieDetail(DetailView):
    queryset = Movie.objects.all_with_related_persons_and_score()

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['image_form'] = self.movie_image_form()
        if self.request.user.is_authenticated:
            # omitting VoteForm code.
        return ctx

 def movie_image_form(self):
        if self.request.user.is_authenticated:
            return MovieImageForm()
        return None

这次,我们的代码更简单,因为用户只能上传新图片,不支持其他操作,这样我们可以始终提供一个空表单。然而,使用这种方法,我们仍然不显示错误消息。丢失错误消息不应被视为最佳实践。

接下来,我们将更新我们的模板以使用我们的新表单和上传的图片。

更新movie_detail.html以显示和上传图片

我们将需要对movie_detail.html模板进行两次更新。首先,我们需要更新我们的main模板块,以显示图片列表。其次,我们需要更新我们的sidebar模板块,以包含我们的上传表单。

首先让我们更新我们的main块:

{% block main %}
  <div class="col" >
    <h1 >{{ object }}</h1 >
    <p class="lead" >
      {{ object.plot }}
    </p >
  </div >
  <ul class="movie-image list-inline" >
    {% for i in object.movieimage_set.all %}
      <li class="list-inline-item" >
          <img src="img/{{ i.image.url }}" >
      </li >
    {% endfor %}
  </ul >
  <p >Directed
    by {{ object.director }}</p >
 {# writers and actors html omitted #}
{% end block %}

我们在前面的代码中使用了image字段的url属性,它返回了MEDIA_URL设置与计算出的文件名连接在一起,这样我们的img标签就可以正确显示图片。

sidebar块中,我们将添加一个上传新图片的表单:

{% block sidebar %}
  {# rating div omitted #}
  {% if image_form %}
    <div >
      <h2 >Upload New Image</h2 >
      <form method="post"
            enctype="multipart/form-data"
            action="{% url 'core:MovieImageUpload' movie_id=object.id %}" >
        {% csrf_token %}
        {{ image_form.as_p }}
        <p >
          <button
              class="btn btn-primary" >
            Upload
          </button >
        </p >
      </form >
    </div >
  {% endif %}
  {# score and voting divs omitted #}
{% endblock %}

这与我们之前的表单非常相似。但是,我们必须记得在我们的form标签中包含enctype属性,以便上传的文件能够正确附加到请求中。

现在我们完成了我们的模板,我们可以创建我们的MovieImageUpload视图来保存我们上传的文件。

编写 MovieImageUpload 视图

我们倒数第二步将是在django/core/views.py中添加一个视图来处理上传的文件:

from django.contrib.auth.mixins import (
    LoginRequiredMixin) 
from django.views.generic import CreateView

from core.forms import MovieImageForm

class MovieImageUpload(LoginRequiredMixin, CreateView):
    form_class = MovieImageForm

    def get_initial(self):
        initial = super().get_initial()
        initial['user'] = self.request.user.id
        initial['movie'] = self.kwargs['movie_id']
        return initial

    def render_to_response(self, context, **response_kwargs):
        movie_id = self.kwargs['movie_id']
        movie_detail_url = reverse(
            'core:MovieDetail',
            kwargs={'pk': movie_id})
        return redirect(
            to=movie_detail_url)

    def get_success_url(self):
        movie_id = self.kwargs['movie_id']
        movie_detail_url = reverse(
            'core:MovieDetail',
            kwargs={'pk': movie_id})
        return movie_detail_url

我们的视图再次将所有验证和保存模型的工作委托给CreateView和我们的表单。我们从请求的user属性中检索user.id属性(因为LoginRequiredMixin类的存在,我们可以确定用户已登录),并从 URL 中获取电影 ID,然后将它们作为初始参数传递给表单,因为MovieImageFormusermovie字段是禁用的(因此它们会忽略请求体中的值)。保存和重命名文件的工作都由 Django 的ImageField完成。

最后,我们可以更新我们的项目,将请求路由到我们的MovieImageUpload视图并提供我们上传的文件。

将请求路由到视图和文件

在这一部分,我们将更新coreURLConf,将请求路由到我们的新MovieImageUpload视图,并看看我们如何在开发中提供我们上传的图片。我们将看看如何在生产中提供上传的图片第五章,使用 Docker 部署

为了将请求路由到我们的MovieImageUpload视图,我们将更新django/core/urls.py

from django.urls import path

from . import views

app_name = 'core'
urlpatterns = [
    # omitted existing paths
    path('movie/<int:movie_id>/image/upload',
         views.MovieImageUpload.as_view(),
         name='MovieImageUpload'),
    # omitted existing paths
]

我们像往常一样添加我们的path()函数,并确保我们记得它需要一个名为movie_id的参数。

现在,Django 将知道如何路由到我们的视图,但它不知道如何提供上传的文件。

在开发中为了提供上传的文件,我们将更新django/config/urls.py

from django.conf import settings
from django.conf.urls.static import (
    static, )
from django.contrib import admin
from django.urls import path, include

import core.urls
import user.urls

MEDIA_FILE_PATHS = static(
    settings.MEDIA_URL,
    document_root=settings.MEDIA_ROOT)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('user/', include(
        user.urls, namespace='user')),
    path('', include(
        core.urls, namespace='core')),
] + MEDIA_FILE_PATHS

Django 提供了static()函数,它将返回一个包含单个path对象的列表,该对象将路由以MEDIA_URL开头的任何请求到document_root内的文件。这将为我们在开发中提供一种服务上传的图像文件的方法。这个功能不适合生产环境,如果settings.DEBUGFalsestatic()将返回一个空列表。

现在我们已经看到了 Django 核心功能的大部分,让我们讨论它如何与开放 Web 应用程序安全项目OWASP)的十大最关键安全风险(OWASP Top 10)列表相关。

OWASP Top 10

OWASP 是一个专注于通过为 Web 应用程序提供公正的实用安全建议来使安全可见的非营利慈善组织。OWASP 的所有材料都是免费和开源的。自 2010 年以来,OWASP 征求信息安全专业人员的数据,并用它来开发 Web 应用程序安全中最关键的十大安全风险的列表(OWASP Top 10)。尽管这个列表并不声称列举所有问题(它只是前十名),但它是基于安全专业人员在野外进行渗透测试和对全球公司的生产或开发中的真实代码进行代码审计时所看到的情况。

Django 被开发为尽可能地减少和避免这些风险,并在可能的情况下,为开发人员提供工具来最小化风险。

让我们列举 2013 年的 OWASP Top 10(撰写时的最新版本,2017 RC1 已被拒绝),并看看 Django 如何帮助我们减轻每个风险。

A1 注入

自 OWASP Top 10 创建以来,这一直是头号问题。注入意味着用户能够注入由我们的系统或我们使用的系统执行的代码。例如,SQL 注入漏洞让攻击者在我们的数据库中执行任意 SQL 代码,这可能导致他们绕过我们几乎所有的控制和安全措施(例如,让他们作为管理员用户进行身份验证;SQL 注入漏洞可能导致 shell 访问)。对于这个问题,特别是对于 SQL 注入,最好的解决方案是使用参数化查询。

Django 通过提供QuerySet类来保护我们免受 SQL 注入的侵害。QuerySet确保它发送的所有查询都是参数化的,以便数据库能够区分我们的 SQL 代码和查询中的值。使用参数化查询将防止 SQL 注入。

然而,Django 允许使用QuerySet.raw()QuerySet.extra()进行原始 SQL 查询。这两种方法都支持参数化查询,但开发人员必须确保他们永远不要使用来自用户的值通过字符串格式化(例如str.format)放入 SQL 查询,而是始终使用参数。

A2 破坏身份验证和会话管理

破坏身份验证会话管理指的是攻击者能够身份验证为另一个用户或接管另一个用户的会话的风险。

Django 在这里以几种方式保护我们,如下:

  • Django 的auth应用程序始终对密码进行哈希和盐处理,因此即使数据库被破坏,用户密码也无法被合理地破解。

  • Django 支持多种慢速哈希算法(例如 Argon2 和 Bcrypt),这使得暴力攻击变得不切实际。这些算法并不是默认提供的(Django 默认使用PBDKDF2),因为它们依赖于第三方库,但可以使用PASSWORD_HASHERS设置进行配置。

  • Django 会话 ID 默认情况下不会在 URL 中公开,并且登录后会更改会话 ID。

然而,Django 的加密功能始终以settings.SECRET_KEY字符串为种子。将SECRET_KEY的生产值检入版本控制应被视为安全问题。该值不应以明文形式共享,我们将在第五章 使用 Docker 部署中讨论。

A3 跨站脚本攻击

跨站脚本攻击XSS)是指攻击者能够让 Web 应用显示攻击者创建的 HTML 或 JavaScript,而不是开发者创建的 HTML 或 JavaScript。这种攻击非常强大,因为如果攻击者可以执行任意 JavaScript,那么他们可以发送请求,这些请求看起来与用户的真实请求无法区分。

Django 默认情况下会对模板中的所有变量进行 HTML 编码保护。

然而,Django 确实提供了将文本标记为安全的实用程序,这将导致值不被编码。这些应该谨慎使用,并充分了解如果滥用会造成严重安全后果。

A4 不安全的直接对象引用

不安全的直接对象引用是指我们在资源引用中不安全地暴露实现细节,而没有保护资源免受非法访问/利用。例如,我们电影详细页面的<img>标签的src属性中的路径直接映射到文件系统中的文件。如果用户操纵 URL,他们可能访问他们本不应访问的图片,从而利用漏洞。或者,使用在 URL 中向用户公开的自动递增主键可以让恶意用户遍历数据库中的所有项目。这种风险的影响高度取决于暴露的资源。

Django 通过不将路由路径与视图耦合来帮助我们。我们可以根据主键进行模型查找,但并不是必须这样做,我们可以向我们的模型添加额外的字段(例如UUIDField)来将表的主键与 URL 中使用的 ID 解耦。在第三部分的 Mail Ape 项目中,我们将看到如何使用UUIDField类作为模型的主键。

A5 安全配置错误

安全配置错误指的是当适当的安全机制被不当部署时所产生的风险。这种风险处于开发和运营的边界,并需要两个团队合作。例如,如果我们在生产环境中以DEBUG设置为True运行我们的 Django 应用,我们将面临在没有任何错误的情况下向公众暴露过多信息的风险。

Django 通过合理的默认设置以及 Django 项目网站上的技术和主题指南来帮助我们。Django 社区也很有帮助——他们在邮件列表和在线博客上发布信息,尽管在线博客文章应该持怀疑态度,直到你验证了它们的声明。

A6 敏感数据暴露

敏感数据暴露是指敏感数据可能在没有适当授权的情况下被访问的风险。这种风险不仅仅是攻击者劫持用户会话,还包括备份存储方式、加密密钥轮换方式,以及最重要的是哪些数据实际上被视为敏感。这些问题的答案是项目/业务特定的。

Django 可以通过配置为仅通过 HTTPS 提供页面来帮助减少来自攻击者使用网络嗅探的意外暴露风险。

然而,Django 并不直接提供加密,也不管理密钥轮换、日志、备份和数据库本身。有许多因素会影响这种风险,这些因素超出了 Django 的范围。

A7 缺少功能级别的访问控制

虽然 A6 指的是数据被暴露,但缺少功能级别的访问控制是指功能受到不充分保护的风险。考虑我们的UpdateVote视图——如果我们忘记了LoginRequiredMixin类,那么任何人都可以发送 HTTP 请求并更改我们用户的投票。

Django 的auth应用程序提供了许多有用的功能来减轻这些问题,包括超出本项目范围的权限系统,以及混合和实用程序,使使用这些权限变得简单(例如,LoginRequiredMixinPermissionRequiredMixin)。

然而,我们需要适当地使用 Django 的工具来完成手头的工作。

A8 跨站点请求伪造(CSRF)

CSRF(发音为see surf)是 OWASP 十大中技术上最复杂的风险。CSRF 依赖于一个事实,即每当浏览器从服务器请求任何资源时,它都会自动发送与该域关联的所有 cookie。恶意攻击者可能会欺骗我们已登录的用户之一,让其查看第三方网站上的页面(例如malicious.example.org),例如,带有指向我们网站的 URL 的img标签的src属性(例如,mymdb.example.com)。当用户的浏览器看到src时,它将向该 URL 发出GET请求,并发送与我们网站相关的所有 cookie(包括会话 ID)。

风险在于,如果我们的 Web 应用程序收到GET请求,它将进行用户未打算的修改。减轻此风险的方法是确保进行任何进行修改的操作(例如,UpdateVote)都具有唯一且不可预测的值(CSRF 令牌),只有我们的系统知道,这确认了用户有意使用我们的应用程序执行此操作。

Django 在很大程度上帮助我们减轻这种风险。Django 提供了csrf_token标签,使向表单添加 CSRF 令牌变得容易。Django 负责添加匹配的 cookie(用于验证令牌),并确保任何使用的动词不是GETHEADOPTIONSTRACE的请求都有有效的 CSRF 令牌进行处理。Django 进一步通过使其所有的通用编辑视图(EditViewCreateViewDeleteViewFormView)仅在POST上执行修改操作,而不是在GET上,来帮助我们做正确的事情。

然而,Django 不能拯救我们免受自身的伤害。如果我们决定禁用此功能或编写具有GET副作用的视图,Django 无法帮助我们。

A9 使用已知漏洞的组件

一条链只有其最薄弱的一环那么强,有时,项目可能在其依赖的框架和库中存在漏洞。

Django 项目有一个安全团队,接受安全问题的机密报告,并有安全披露政策,以使社区了解影响其项目的问题。一般来说,Django 发布后会在首次发布后的 16 个月内获得支持(包括安全更新),但长期支持LTS)发布将获得 3 年的支持(下一个 LTS 发布将是 Django 2.2)。

然而,Django 不会自动更新自身,也不会强制我们运行最新版本。每个部署都必须自行管理这一点。

A10 未经验证的重定向和转发

如果我们的网站可以自动将用户重定向/转发到第三方网站,那么我们的网站就有可能被用来欺骗用户被转发到恶意网站。

Django 通过确保LoginViewnext参数只会转发用户的 URL,这些 URL 是我们项目的一部分,来保护我们。

然而,Django 不能保护我们免受自身的伤害。我们必须确保我们从不使用用户提供的未经验证的数据作为 HTTP 重定向或转发的基础。

总结

在本节中,我们已更新我们的应用程序,以便用户上传与电影相关的图像,并审查了 OWASP 十大。我们介绍了 Django 如何保护我们,以及我们需要保护自己的地方。

接下来,我们将构建一个前十名电影列表,并看看如何使用缓存来避免每次扫描整个数据库。

第四章:在前 10 部电影中进行缓存

在本章中,我们将使用我们的用户投票的票数来构建 MyMDB 中前 10 部电影的列表。为了确保这个受欢迎的页面保持快速加载,我们将看看帮助我们优化网站的工具。最后,我们将看看 Django 的缓存 API 以及如何使用它来优化我们的项目。

在本章中,我们将做以下事情:

  • 使用聚合查询创建一个前 10 部电影列表

  • 了解 Django 的工具来衡量优化

  • 使用 Django 的缓存 API 来缓存昂贵操作的结果

让我们从制作我们的前 10 部电影列表页面开始。

创建前 10 部电影列表

为了构建我们的前 10 部电影列表,我们将首先创建一个新的MovieManager方法,然后在新的视图和模板中使用它。我们还将更新基本模板中的顶部标题,以便从每个页面轻松访问列表。

创建 MovieManager.top_movies()

我们的MovieManager类需要能够返回一个由我们的用户投票选出的最受欢迎电影的QuerySet对象。我们使用了一个天真的受欢迎度公式,即票数减去票数的总和。就像在第二章将用户添加到 MyMDB中一样,我们将使用QuerySet.annotate()方法来进行聚合查询以计算投票数。

让我们将我们的新方法添加到django/core/models.py

from django.db.models.aggregates import (
    Sum
)

class MovieManager(models.Manager):

    # other methods omitted

    def top_movies(self, limit=10):
        qs = self.get_queryset()
        qs = qs.annotate(
            vote_sum=Sum('vote__value'))
        qs = qs.exclude(
            vote_sum=None)
        qs = qs.order_by('-vote_sum')
        qs = qs[:limit]
        return qs

我们按照它们的票数总和(降序)对结果进行排序,以获得我们的前 10 部电影列表。然而,我们面临的问题是,一些电影没有投票,因此它们的vote_sum值将为NULL。不幸的是,NULL将首先被 Postgres 排序。我们将通过添加一个约束来解决这个问题,即没有投票的电影,根据定义,不会成为前 10 部电影之一。我们使用QuerySet.exclude(与QuerySet.filter相反)来删除没有投票的电影。

这是我们第一次看到一个QuerySet对象被切片。除非提供步长,否则QuerySet对象不会被切片评估(例如,qs [10:20:2]会使QuerySet对象立即被评估并返回第 10、12、14、16 和 18 行)。

现在我们有了一个合适的Movie模型实例的QuerySet对象,我们可以在视图中使用QuerySet对象。

创建 TopMovies 视图

由于我们的TopMovies视图需要显示一个列表,我们可以像以前一样使用 Django 的ListView。让我们更新django/core/views.py

from django.views.generic import ListView
from core.models import Movie

class TopMovies(ListView):
    template_name = 'core/top_movies_list.html'
    queryset = Movie.objects.top_movies(
        limit=10)

与以前的ListView类不同,我们需要指定一个template_name属性。否则,ListView将尝试使用core/movie_list.html,这是MovieList视图使用的。

接下来,让我们创建我们的模板。

创建 top_movies_list.html 模板

我们的前 10 部电影页面不需要分页,所以模板非常简单。让我们创建django/core/templates/core/top_movies_list.html

{% extends "base.html" %}

{% block title %}
  Top 10 Movies
{% endblock %}

{% block main %}
  <h1 >Top 10 Movies</h1 >
  <ol >
    {% for movie in object_list %}
      <li >
        <a href="{% url "core:MovieDetail" pk=movie.id %}" >
          {{ movie }}
        </a >
      </li >
    {% endfor %}
  </ol >
{% endblock %}

扩展base.html,我们将重新定义两个模板block标签。新的title模板block有我们的新标题。main模板block列出了object_list中的电影,包括每部电影的链接。

最后,让我们更新django/templates/base.html,以包括一个链接到我们的前 10 部电影页面:

{# rest of template omitted #}
<div class="mymdb-masthead">
  <div class="container">
    <nav class="nav">
       {# skipping other nav items #}
       <a
          class="nav-link"
          href="{% url 'core:TopMovies' %}"
        >
        Top 10 Movies
       </a>
       {# skipping other nav items #}
      </nav>
   </div>
</div>
{# rest of template omitted #}

现在,让我们在我们的 URLConf 中添加一个path()对象,这样 Django 就可以将请求路由到我们的TopMovies视图。

添加到 TopMovies 的路径

像往常一样,我们需要添加一个path()来帮助 Django 将请求路由到我们的视图。让我们更新django/core/urls.py

from django.urls import path

from . import views

app_name = 'core'
urlpatterns = [
    path('movies',
         views.MovieList.as_view(),
         name='MovieList'),
    path('movies/top',
         views.TopMovies.as_view(),
         name="TopMovies"),
    # other paths omitted
 ]

有了这个,我们就完成了。现在我们在 MyMDB 上有了一个前 10 部电影页面。

然而,浏览所有的投票意味着扫描项目中最大的表。让我们看看如何优化我们的项目。

优化 Django 项目

如何优化 Django 项目没有单一正确答案,因为不同的项目有不同的约束。要成功,重要的是要清楚你要优化什么,以及在硬数据中使用什么,而不是直觉。

清楚地了解我们要进行优化的内容很重要,因为优化通常涉及权衡。您可能希望进行优化的一些约束条件如下:

  • 响应时间

  • Web 服务器内存

  • Web 服务器 CPU

  • 数据库内存

一旦您知道要进行优化的内容,您将需要一种方法来测量当前性能和优化代码的性能。优化代码通常比未优化代码更复杂。在承担复杂性之前,您应始终确认优化是否有效。

Django 只是 Python,因此您可以使用 Python 分析器来测量性能。这是一种有用但复杂的技术。讨论 Python 分析的细节超出了本书的范围。然而,重要的是要记住 Python 分析是我们可以使用的有用工具。

让我们看看一些特定于 Django 的测量性能的方法。

使用 Django 调试工具栏

Django 调试工具栏是一个第三方包,可以在浏览器中提供大量有用的调试信息。工具栏由一系列面板组成。每个面板提供不同的信息集。

一些最有用的面板(默认情况下启用)如下:

  • 请求面板:它显示与请求相关的信息,包括处理请求的视图、接收到的参数(从路径中解析出来)、cookie、会话数据以及请求中的 GET/POST 数据。

  • SQL 面板:显示进行了多少查询,它们的执行时间线以及在查询上运行EXPLAIN的按钮。数据驱动的 Web 应用程序通常会因其数据库查询而变慢。

  • 模板面板:显示已呈现的模板及其上下文。

  • 日志面板:它显示视图产生的任何日志消息。我们将在下一节讨论更多关于日志记录的内容。

配置文件面板是一个高级面板,默认情况下不启用。该面板在您的视图上运行分析器并显示结果。该面板带有一些注意事项,这些注意事项在 Django 调试工具栏在线文档中有解释(django-debug-toolbar.readthedocs.io/en/stable/panels.html#profiling)。

Django 调试工具栏在开发中很有用,但不应在生产中运行。默认情况下,只有在DEBUG = True时才能工作(这是您在生产中绝对不能使用的设置)。

使用日志记录

Django 使用 Python 的内置日志系统,您可以使用settings.LOGGING进行配置。它使用DictConfig进行配置,如 Python 文档中所述。

作为一个复习,这是 Python 的日志系统的工作原理。该系统由记录器组成,它们从我们的代码接收消息日志级别(例如DEBUGINFO)。如果记录器被配置为不过滤掉该日志级别(或更高级别)的消息,它将创建一个日志记录,并将其传递给所有其处理程序。处理程序将检查它是否与处理程序的日志级别匹配,然后它将格式化日志记录(使用格式化程序)并发出消息。不同的处理程序将以不同的方式发出消息。StreamHandler将写入流(默认为sys.stderr),SysLogHandler写入SysLogSMTPHandler发送电子邮件。

通过记录操作所需的时间,您可以对需要进行优化的内容有一个有意义的了解。使用正确的日志级别和处理程序,您可以在生产中测量资源消耗。

应用性能管理

应用性能管理(APM)是指作为应用服务器一部分运行并跟踪执行操作的服务。跟踪结果被发送到报告服务器,该服务器将所有跟踪结果合并,并可以为您提供对生产服务器性能的代码行级洞察。这对于大型和复杂的部署可能有所帮助,但对于较小、较简单的 Web 应用程序可能过于复杂。

本节的快速回顾

在本节中,我们回顾了在实际开始优化之前知道要优化什么的重要性。我们还看了一些工具,帮助我们衡量我们的优化是否成功。

接下来,我们将看看如何使用 Django 的缓存 API 解决一些常见的性能问题。

使用 Django 的缓存 API

Django 提供了一个开箱即用的缓存 API。在settings.py中,您可以配置一个或多个缓存。缓存可用于存储整个站点、单个页面的响应、模板片段或任何可 pickle 的对象。Django 提供了一个可以配置多种后端的单一 API。

在本节中,我们将执行以下功能:

  • 查看 Django 缓存 API 的不同后端

  • 使用 Django 缓存页面

  • 使用 Django 缓存模板片段

  • 使用 Django 缓存QuerySet

我们不会研究下游缓存,例如内容交付网络CDN)或代理缓存。这些不是 Django 特有的,有各种各样的选择。一般来说,这些类型的缓存将依赖于 Django 已发送的相同VARY标头。

接下来,让我们看看如何配置缓存 API 的后端。

检查 Django 缓存后端之间的权衡

不同的后端可能适用于不同的情况。但是,缓存的黄金法则是它们必须比它们缓存的源更快,否则您会使应用程序变慢。决定哪个后端适合哪个任务最好是通过对项目进行仪器化来完成的,如前一节所讨论的。不同的后端有不同的权衡。

检查 Memcached 的权衡

Memcached是最受欢迎的缓存后端,但仍然存在需要评估的权衡。Memcached 是一个用于小数据的内存键值存储,可以由多个客户端(例如 Django 进程)使用一个或多个 Memcached 主机进行共享。但是,Memcached 不适合缓存大块数据(默认情况下为 1 MB 的数据)。另外,由于 Memcached 全部在内存中,如果进程重新启动,则整个缓存将被清除。另一方面,Memcached 因为快速和简单而保持受欢迎。

Django 带有两个 Memcached 后端,取决于您想要使用的Memcached库:

  • django.core.cache.backends.memcached.MemcachedCache

  • django.core.cache.backends.memcached.PyLibMCCache

您还必须安装适当的库(python-memcachedpylibmc)。要将您的 Memcached 服务器的地址设置为LOCATION,请将其设置为格式为address:PORT的列表(例如,['memcached.example.com:11211',])。示例配置在本节末尾列出。

开发测试中使用 Memcached 可能不会很有用,除非您有相反的证据(例如,您需要复制一个复杂的错误)。

Memcached 在生产环境中很受欢迎,因为它快速且易于设置。它通过让所有 Django 进程连接到相同的主机来避免数据重复。但是,它使用大量内存(并且在可用内存用尽时会迅速且不良地降级)。另外,注意运行另一个服务的操作成本是很重要的。

以下是使用memcached的示例配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
        'LOCATION':  [
            '127.0.0.1:11211',
        ],
    }
}

检查虚拟缓存的权衡

虚拟缓存django.core.cache.backends.dummy.DummyCache)将检查密钥是否有效,但否则不执行任何操作。

当您想确保您确实看到代码更改的结果而不是缓存时,此缓存在开发测试中可能很有用。

不要在生产中使用此缓存,因为它没有效果。

以下是一个虚拟缓存的示例配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
    }
}

检查本地内存缓存的权衡

本地内存缓存django.core.cache.backends.locmem.LocMemCache)使用 Python 字典作为全局内存缓存。如果要使用多个单独的本地内存缓存,请在LOCATION中给出每个唯一的字符串。它被称为本地缓存,因为它是每个进程的本地缓存。如果您正在启动多个进程(就像在生产中一样),那么不同进程处理请求时可能会多次缓存相同的值。这种低效可能更简单,因为它不需要另一个服务。

这是一个在开发测试中使用的有用缓存,以确认您的代码是否正确缓存。

您可能想在生产中使用这个,但要记住不同进程缓存相同数据的潜在低效性。

以下是本地内存缓存的示例配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'defaultcache',

    },
    'otherCache': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'othercache',
    }
}

检查基于文件的缓存权衡

Django 的基于文件的缓存django.core.cache.backends.filebased.FileBasedCache)使用指定的LOCATION目录中的压缩文件来缓存数据。使用文件可能看起来很奇怪;缓存不应该是快速的,而文件是的吗?答案再次取决于您要缓存的内容。例如,对外部 API 的网络请求可能比本地磁盘慢。请记住,每个服务器都将有一个单独的磁盘,因此如果您运行一个集群,数据将会有一些重复。

除非内存受限,否则您可能不想在开发测试中使用这个。

您可能想在生产中缓存特别大或请求速度慢的资源。请记住,您应该给服务器进程写入LOCATION目录的权限。此外,请确保为缓存给服务器提供足够的磁盘空间。

以下是使用基于文件的缓存的示例配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
        'LOCATION': os.path.join(BASE_DIR, '../file_cache'),
    }
}

检查数据库缓存权衡

数据库缓存后端(django.core.cache.backends.db.DatabaseCache)使用数据库表(在LOCATION中命名)来存储缓存。显然,如果您的数据库速度很快,这将效果最佳。根据情况,即使在缓存数据库查询结果时,这也可能有所帮助,如果查询复杂但单行查找很快。这有其优势,因为缓存不像内存缓存那样是短暂的,可以很容易地在进程和服务器之间共享(如 Memcached)。

数据库缓存表不是由迁移管理的,而是由manage.py命令管理,如下所示:

$ cd django
$ python manage.py createcachetable

除非您想在开发测试中复制您的生产环境,否则您可能不想使用这个。

如果您的测试证明它是合适的,您可能想在生产中使用这个。请记住考虑增加的数据库负载对性能的影响。

以下是使用数据库缓存的示例配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
        'LOCATION': 'django_cache_table',
    }
}

配置本地内存缓存

在我们的情况下,我们将使用一个具有非常低超时的本地内存缓存。这意味着我们在编写代码时大多数请求将跳过缓存(旧值(如果有)将已过期),但如果我们快速点击刷新,我们将能够确认我们的缓存正在工作。

让我们更新django/config/settings.py以使用本地内存缓存:

 CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'default-locmemcache',
        'TIMEOUT': 5, # 5 seconds
    }
 }

尽管我们可以有多个配置不同的缓存,但默认缓存的名称应为'default'

Timeout是值在被清除(移除/忽略)之前在缓存中保留的时间(以秒为单位)。如果TimeoutNone,则该值将被视为永不过期。

现在我们已经配置了缓存,让我们缓存MovieList页面。

缓存电影列表页面

我们将假设MovieList页面对我们来说非常受欢迎且昂贵。为了降低提供这些请求的成本,我们将使用 Django 来缓存整个页面。

Django 提供了装饰器(函数)django.views.decorators.cache.cache_page,它可以用来缓存单个页面。这是一个装饰器而不是一个 mixin,可能看起来有点奇怪。当 Django 最初发布时,它没有 基于类的视图CBVs),只有 基于函数的视图FBVs)。随着 Django 的成熟,很多代码切换到使用 CBVs,但仍然有一些功能实现为 FBV 装饰器。

在 CBVs 中,有几种不同的使用函数装饰器的方式。我们的方法是构建我们自己的 mixin。CBVs 的很多功能来自于能够将新行为混入到现有类中的能力。了解如何做到这一点是一项有用的技能。

创建我们的第一个 mixin – CachePageVaryOnCookieMixin

让我们在 django/core/mixins.py 中创建一个新的类:

from django.core.cache import caches
from django.views.decorators.cache import (
    cache_page)

class CachePageVaryOnCookieMixin:
    """
    Mixin caching a single page.

    Subclasses can provide these attributes:

    `cache_name` - name of cache to use.
    `timeout` - cache timeout for this
    page. When not provided, the default
    cache timeout is used. 
    """
    cache_name = 'default'

    @classmethod
    def get_timeout(cls):
        if hasattr(cls, 'timeout'):
            return cls.timeout
        cache = caches[cls.cache_name]
        return cache.default_timeout

    @classmethod
    def as_view(cls, *args, **kwargs):
        view = super().as_view(
            *args, **kwargs)
        view = vary_on_cookie(view)
        view = cache_page(
            timeout=cls.get_timeout(),
            cache=cls.cache_name,
        )(view)
        return view

我们的新 mixin 覆盖了我们在 URLConfs 中使用的 as_view() 类方法,并使用 vary_on_cookie()cache_page() 装饰器装饰视图。这实际上就像我们在 as_view() 方法上使用我们的函数装饰器一样。

让我们先看看 cache_page() 装饰器。cache_page() 需要一个 timeout 参数,并且可以选择接受一个 cache 参数。timeout 是缓存页面应该过期并且必须重新缓存之前的时间(以秒为单位)。我们的默认超时值是我们正在使用的缓存的默认值。子类化 CachePageVaryOnCookieMixin 的类可以提供一个新的 timeout 属性,就像我们的 MovieList 类提供了一个 model 属性一样。cache 参数期望所需缓存的字符串名称。我们的 mixin 被设置为使用 default 缓存,但通过引用一个类属性,这也可以被子类更改。

当缓存一个页面,比如 MoveList,我们必须记住,对于不同的用户,生成的页面是不同的。在我们的情况下,MovieList 的头对已登录用户(显示 注销 链接)和已注销用户(显示 登录注册 链接)是不同的。Django 再次为我们提供了 vary_on_cookie() 装饰器。

vary_on_cookie() 装饰器将一个 VARY cookie 头添加到响应中。VARY 头被缓存(包括下游缓存和 Django 的缓存)用来告诉它们有关该资源的变体。VARY cookie 告诉缓存,每个不同的 cookie/URL 对都是不同的资源,应该分别缓存。这意味着已登录用户和已注销用户将看到不同的页面,因为它们将有不同的 cookie。

这对我们的命中率(缓存被 命中 而不是重新生成资源的比例)有重要影响。命中率低的缓存将几乎没有效果,因为大多数请求将 未命中 缓存,并导致处理请求。

在我们的情况下,我们还使用 cookie 进行 CSRF 保护。虽然会话 cookie 可能会降低命中率一点,具体取决于情况(查看用户的活动以确认),但 CSRF cookie 几乎是致命的。CSRF cookie 的性质是经常变化,以便攻击者无法预测。如果那个不断变化的值与许多请求一起发送,那么很少能被缓存。幸运的是,我们可以将我们的 CSRF 值从 cookie 移出,并将其存储在服务器端会话中,只需通过 settings.py 进行更改。

为您的应用程序决定正确的 CSRF 策略可能是复杂的。例如,AJAX 应用程序将希望通过标头添加 CSRF 令牌。对于大多数站点,默认的 Django 配置(使用 cookie)是可以的。如果您需要更改它,值得查看 Django 的 CSRF 保护文档(docs.djangoproject.com/en/2.0/ref/csrf/)。

django/conf/settings.py 中,添加以下代码:

CSRF_USE_SESSIONS = True

现在,Django 不会将 CSRF 令牌发送到 cookie 中,而是将其存储在用户的会话中(存储在服务器上)。

如果用户已经有 CSRF cookie,它们将被忽略;但是,它仍然会对命中率产生抑制作用。在生产环境中,您可能希望考虑添加一些代码来删除这些 CSRF cookie。

现在我们有了一种轻松混合缓存行为的方法,让我们在MovieList视图中使用它。

使用 CachePageVaryOnCookieMixin 与 MovieList

让我们在django/core/views.py中更新我们的视图:

from django.views.generic import ListView
from core.mixins import (
    VaryCacheOnCookieMixin)

class MovieList(VaryCacheOnCookieMixin, ListView):
    model = Movie
    paginate_by = 10

    def get_context_data(self, **kwargs):
        # omitted due to no change

现在,当MovieList收到路由请求时,cache_page将检查它是否已被缓存。如果已经被缓存,Django 将返回缓存的响应,而不做任何其他工作。如果没有被缓存,我们常规的MovieList视图将创建一个新的响应。新的响应将添加一个VARY cookie头,然后被缓存。

接下来,让我们尝试在模板中缓存我们的前 10 部电影列表的一部分。

使用{% cache %}缓存模板片段

有时,页面加载缓慢是因为我们模板的某个部分很慢。在本节中,我们将看看如何通过缓存模板的片段来解决这个问题。例如,如果您使用的标签需要很长时间才能解析(比如,因为它发出了网络请求),那么它将减慢使用该标签的任何页面。如果无法优化标签本身,将模板中的结果缓存可能就足够了。

通过编辑django/core/templates/core/top_movies.html来缓存我们渲染的前 10 部电影列表:

{% extends "base.html" %}
{% load cache %}

{% block title %}
  Top 10 Movies
{% endblock %}

{% block main %}
  <h1 >Top 10 Movies</h1 >
  {% cache 300 top10 %}
  <ol >
    {% for movie in object_list %}
      <li >
        <a href="{% url "core:MovieDetail" pk=movie.id %}" >
          {{ movie }}
        </a >
      </li >
    {% endfor %}
  </ol >
  {% endcache %}
{% endblock %}

这个块向我们介绍了{% load %}标签和{% cache %}标签。

{% load %}标签用于加载标签和过滤器的库,并使它们可用于模板中使用。一个库可以提供一个或多个标签和/或过滤器。例如,{% load humanize %}加载标签和过滤器,使值看起来更人性化。在我们的情况下,{% load cache %}只提供了{% cache %}标签。

{% cache 300 top10 %}将在提供的秒数下缓存标签的主体,并使用提供的键。第二个参数必须是一个硬编码的字符串(而不是一个变量),但如果片段需要有变体,我们可以提供更多的参数(例如,{% cache 300 mykey request.user.id %}为每个用户缓存一个单独的片段)。该标签将使用default缓存,除非最后一个参数是using='cachename',在这种情况下,将使用命名缓存。

使用{% cache %}进行缓存发生在不同的级别,而不是使用cache_pagevary_on_cookie。视图中的所有代码仍将被执行。视图中的任何缓慢代码仍将减慢我们的速度。缓存模板片段只解决了我们模板代码中一个非常特定的缓慢片段的问题。

由于QuerySets是懒惰的,通过将我们的for循环放在{% cache %}中,我们避免了评估QuerySet。如果我们想缓存一个值以避免查询它,如果我们在视图中这样做,我们的代码会更清晰。

接下来,让我们看看如何使用 Django 的缓存 API 缓存对象。

使用对象的缓存 API

Django 的缓存 API 最精细的用法是存储与 Python 的pickle序列化模块兼容的对象。我们将在这里看到的cache.get()/cache.set()方法在cache_page()装饰器和{% cache %}标签内部使用。在本节中,我们将使用这些方法来缓存Movie.objects.top_movies()返回的QuerySet

方便的是,QuerySet对象是可 pickle 的。当QuerySets被 pickled 时,它将立即被评估,并且生成的模型将存储在QuerySet的内置缓存中。在 unpickling 一个QuerySet时,我们可以迭代它而不会引起新的查询。如果QuerySetselect_relatedprefetch_related,那些查询将在 pickling 时执行,而在 unpickling 时不会重新运行。

让我们从top_movies_list.html中删除{% cache %}标签,而是更新django/core/views.py

import django
from django.core.cache import cache
from django.views.generic import ListView

from core.models import Movie

class TopMovies(ListView):
    template_name = 'core/top_movies_list.html'

    def get_queryset(self):
        limit = 10
        key = 'top_movies_%s' % limit
        cached_qs = cache.get(key)
        if cached_qs:
            same_django = cached_qs._django_version == django.get_version()
            if same_django:
                return cached_qs
        qs = Movie.objects.top_movies(
            limit=limit)
        cache.set(key, qs)
        return qs

我们的新TopMovies视图重写了get_queryset方法,并在使用MovieManger.top_movies()之前检查缓存。对QuerySet对象进行 pickling 确实有一个警告——不能保证在不同的 Django 版本中兼容,因此在继续之前应该检查所使用的版本。

TopMovies还展示了一种访问默认缓存的不同方式,而不是VaryOnCookieCache使用的方式。在这里,我们导入并使用django.core.cache.cache,它是django.core.cache.caches['default']的代理。

在使用低级 API 进行缓存时,记住一致的键的重要性是很重要的。在大型代码库中,很容易在不同的键下存储相同的数据,导致效率低下。将缓存代码放入管理器或实用程序模块中可能很方便。

总结

在本章中,我们创建了一个 Top 10 电影视图,审查了用于检测 Django 代码的工具,并介绍了如何使用 Django 的缓存 API。Django 和 Django 社区提供了帮助您发现在哪里优化代码的工具,包括使用分析器、Django 调试工具栏和日志记录。Django 的缓存 API 通过cache_page缓存整个页面,通过模板标签{% cache %}缓存模板片段,以及通过cache.set/cache.get缓存任何可 picklable 对象,为我们提供了丰富的 API。

接下来,我们将使用 Docker 部署 MyMDB。

第五章:使用 Docker 部署

在本章中,我们将看看如何使用托管在亚马逊的电子计算云EC2)上的 Docker 容器将 MyMDB 部署到生产环境。我们还将使用亚马逊网络服务AWS)的简单存储服务S3)来存储用户上传的文件。

我们将做以下事情:

  • 将我们的要求和设置文件拆分为单独的开发和生产设置

  • 为 MyMDB 构建一个 Docker 容器

  • 构建数据库容器

  • 使用 Docker Compose 启动两个容器

  • 在云中的 Linux 服务器上将 MyMDB 启动到生产环境

首先,让我们拆分我们的要求和设置,以便保持开发和生产值分开。

为生产和开发组织配置

到目前为止,我们保留了一个要求文件和一个settings.py文件。这使得开发变得方便。但是,我们不能在生产中使用我们的开发设置。

当前的最佳实践是为每个环境使用单独的文件。然后,每个环境的文件都导入具有共享值的公共文件。我们将使用此模式进行要求和设置文件。

让我们首先拆分我们的要求文件。

拆分要求文件

让我们在项目的根目录下创建requirements.common.txt

django<2.1
psycopg2
Pillow<4.4.0

无论我们处于哪种环境,我们始终需要 Django、Postgres 驱动程序和 Pillow(用于ImageField类)。但是,此要求文件永远不会直接使用。

接下来,让我们在requirements.dev.txt中列出我们的开发要求:

-r requirements.common.txt
django-debug-toolbar==1.8

上述文件将安装来自requirements.common.txt(感谢-r)和 Django 调试工具栏的所有内容。

对于我们的生产软件包,我们将使用requirements.production.txt

-r requirements.common.txt
django-storages==1.6.5
boto3==1.4.7
uwsgi==2.0.15

这也将安装来自requirements.common.txt的软件包。它还将安装boto3django-storages软件包,以帮助我们轻松地将文件上传到 S3。uwsgi软件包将提供我们用于提供 Django 的服务器。

要为生产环境安装软件包,我们现在可以执行以下命令:

$ pip install -r requirements.production.txt

接下来,让我们按类似的方式拆分设置文件。

拆分设置文件

再次,我们将遵循当前的 Django 最佳实践,将我们的设置文件分成以下三个文件:common_settings.pyproduction_settings.pydev_settings.py

创建 common_settings.py

我们将通过将当前的settings.py文件重命名为common_settings.py,然后进行本节中提到的更改来创建common_settings.py

让我们将DEBUG = False更改为不会意外处于调试模式的新设置文件。然后,让我们更改SECRET_KEY设置,以便通过更改其行来从环境变量获取其值:

SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')

让我们还添加一个新的设置STATIC_ROOTSTATIC_ROOT是 Django 将从已安装的应用程序中收集所有静态文件的目录,以便更容易地提供它们:

STATIC_ROOT = os.path.join(BASE_DIR, 'gathered_static_files')

在数据库配置中,我们可以删除所有凭据,但保留ENGINE值(为了明确起见,我们打算在任何地方都使用 Postgres):

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
    }
}

最后,让我们删除CACHES设置。这将在每个环境中以不同的方式配置。

接下来,让我们创建一个开发设置文件。

创建 dev_settings.py

我们的开发设置将在django/config/dev_settings.py中。我们将逐步构建它。

首先,我们将从common_settings中导入所有内容:

from config.common_settings import *

然后,我们将覆盖DEBUGSECRET_KEY设置:

DEBUG = True
SECRET_KEY = 'some secret'

在开发中,我们希望以调试模式运行。我们还会感到安全,硬编码一个秘密密钥,因为我们知道它不会在生产中使用。

接下来,让我们更新INSTALLED_APPS列表:

INSTALLED_APPS += [
    'debug_toolbar',
]

在开发中,我们可以通过将一系列仅用于开发的应用程序附加到INSTALLED_APPS列表中来运行额外的应用程序(例如 Django 调试工具栏)。

然后,让我们更新数据库配置:

DATABASES['default'].update({
    'NAME': 'mymdb',
    'USER': 'mymdb',
    'PASSWORD': 'development',
    'HOST': 'localhost',
    'PORT': '5432',
})

由于我们的开发数据库是本地的,我们可以在设置中硬编码值,使文件更简单。如果您的数据库不是本地的,请避免将密码检入版本控制,并在生产中使用os.getenv()

接下来,让我们更新缓存配置:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'default-locmemcache',
        'TIMEOUT': 5,
    }
}

在我们的开发缓存中,我们将使用非常短的超时时间。

最后,我们需要设置文件上传目录:

# file uploads
MEDIA_ROOT = os.path.join(BASE_DIR, '../media_root')

在开发中,我们将在本地文件系统中存储上传的文件。我们将使用MEDIA_ROOT指定要上传到的目录。

Django Debug Toolbar 也需要一些配置:

# Django Debug Toolbar
INTERNAL_IPS = [
    '127.0.0.1',
]

Django Debug Toolbar 只会在预定义的 IP 上呈现,所以我们会给它我们的本地 IP,这样我们就可以在本地使用它。

我们还可以添加我们的开发专用应用程序可能需要的更多设置。

接下来,让我们添加生产设置。

创建 production_settings.py

让我们在django/config/production_settings.py中创建我们的生产设置。

production_settings.py类似于dev_settings.py,但通常使用os.getenv()从环境变量中获取值。这有助于我们将秘密信息(例如密码、API 令牌等)排除在版本控制之外,并将设置与特定服务器解耦:

from config.common_settings import * 
DEBUG = False
assert SECRET_KEY is not None, (
    'Please provide DJANGO_SECRET_KEY '
    'environment variable with a value')
ALLOWED_HOSTS += [
    os.getenv('DJANGO_ALLOWED_HOSTS'),
]

首先,我们导入通用设置。出于谨慎起见,我们确保调试模式已关闭。

设置SECRET_KEY对于我们的系统保持安全至关重要。我们使用assert来防止 Django 在没有SECRET_KEY的情况下启动。common_settings模块应该已经从环境变量中设置了它。

生产网站将从除localhost之外的域访问。然后我们通过将DJANGO_ALLOWED_HOSTS环境变量附加到ALLOWED_HOSTS列表来告诉 Django 我们正在服务的其他域。

接下来,我们将更新数据库配置:

DATABASES['default'].update({
    'NAME': os.getenv('DJANGO_DB_NAME'),
    'USER': os.getenv('DJANGO_DB_USER'),
    'PASSWORD': os.getenv('DJANGO_DB_PASSWORD'),
    'HOST': os.getenv('DJANGO_DB_HOST'),
    'PORT': os.getenv('DJANGO_DB_PORT'),
})

我们使用来自环境变量的值更新数据库配置。

然后,需要设置缓存配置。

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
        'LOCATION': 'default-locmemcache',
        'TIMEOUT': int(os.getenv('DJANGO_CACHE_TIMEOUT'), ),
    }
}

在生产中,我们将接受本地内存缓存的权衡。我们使用另一个环境变量在运行时配置超时时间。

接下来,需要设置文件上传配置设置。

# file uploads
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY_ID')
AWS_STORAGE_BUCKET_NAME = os.getenv('DJANGO_UPLOAD_S3_BUCKET')

在生产中,我们不会将上传的图像存储在容器的本地文件系统上。Docker 的一个核心概念是容器是短暂的。停止和删除容器并用另一个替换应该是可以接受的。如果我们将上传的图像存储在本地,我们将违背这一理念。

不将上传的文件存储在本地的另一个原因是,它们也应该从不同的域提供服务(我们在第三章中讨论过这个问题,海报、头像和安全性)。我们将使用 S3 存储,因为它便宜且易于使用。

django-storages应用程序为许多 CDN 提供文件存储后端,包括 S3。我们告诉 Django 使用 S3,方法是更改DEFAULT_FILE_STORAGE设置。S3Boto3Storage后端需要一些额外的设置才能与 AWS 一起工作,包括 AWS 访问密钥、AWS 秘密访问密钥和目标存储桶的名称。我们将在 AWS 部分稍后讨论这两个访问密钥。

现在我们的设置已经组织好了,我们可以创建我们的 MyMDB Dockerfile

创建 MyMDB Dockerfile

在本节中,我们将为 MyMDB 创建一个 Dockerfile。Docker 基于镜像运行容器。镜像由 Dockerfile 定义。Dockerfile 必须扩展另一个 Dockerfile(保留的scratch镜像是这个周期的结束)。

Docker 的理念是每个容器应该只有一个关注点(目的)。这可能意味着它运行一个单一进程,或者它可能运行多个一起工作的进程。在我们的情况下,它将运行 uWSGI 和 Nginx 进程来提供 MyMDB。

令人困惑的是,Dockerfile 既指预期的文件名,也指文件类型。所以Dockerfile是一个 Dockerfile。

让我们在项目的根目录中创建一个名为Dockerfile的文件。 Dockerfile 使用自己的语言来定义图像中的文件/目录,以及在制作图像时需要运行的任何命令。编写 Dockerfile 的完整指南超出了本章的范围。相反,我们将逐步构建我们的Dockerfile,仅讨论最相关的元素。

我们将通过以下六个步骤构建我们的Dockerfile

  1. 初始化基础镜像并将源代码添加到镜像中

  2. 安装软件包

  3. 收集静态文件

  4. 配置 Nginx

  5. 配置 uWSGI

  6. 清理不必要的资源

启动我们的 Dockerfile

我们的Dockerfile的第一部分告诉 Docker 要使用哪个镜像作为基础,添加我们的代码,并创建一些常见的目录:

FROM phusion/baseimage

# add code and directories
RUN mkdir /mymdb
WORKDIR /mymdb
COPY requirements* /mymdb/
COPY django/ /mymdb/django
COPY scripts/ /mymdb/scripts
RUN mkdir /var/log/mymdb/
RUN touch /var/log/mymdb/mymdb.log

让我们更详细地看看这些说明:

  • FROM:Dockerfile 中需要这个。FROM告诉 Docker 我们的镜像要使用哪个基础镜像。我们将使用phusion/baseimage,因为它提供了许多方便的设施并且占用的内存很少。它是一个专为 Docker 定制的 Ubuntu 镜像,具有一个更小、易于使用的 init 服务管理器,称为 runit(而不是 Ubuntu 的 upstart)。

  • RUN:这在构建图像的过程中执行命令。RUN mkdir /mymdb创建我们将存储文件的目录。

  • WORKDIR:这为我们所有未来的RUN命令设置了工作目录。

  • COPY:这将文件(或目录)从我们的文件系统添加到图像中。源路径是相对于包含我们的Dockerfile的目录的。最好将目标路径设置为绝对路径。

我们还将引用一个名为scripts的新目录。让我们在项目目录的根目录中创建它:

$ mkdir scripts

作为配置和构建新镜像的一部分,我们将创建一些小的 bash 脚本,我们将保存在scripts目录中。

在 Dockerfile 中安装软件包

接下来,我们将告诉我们的Dockerfile安装我们将需要的所有软件包:

RUN apt-get -y update
RUN apt-get install -y \
    nginx \
    postgresql-client \
    python3 \
    python3-pip
RUN pip3 install virtualenv
RUN virtualenv /mymdb/venv
RUN bash /mymdb/scripts/pip_install.sh /mymdb

我们使用RUN语句来安装 Ubuntu 软件包并创建虚拟环境。要将我们的 Python 软件包安装到虚拟环境中,我们将在scripts/pip_install.sh中创建一个小脚本:

#!/usr/bin/env bash

root=$1
source $root/venv/bin/activate

pip3 install -r $root/requirements.production.txt

上述脚本只是激活虚拟环境并在我们的生产需求文件上运行pip3 install

在 Dockerfile 的中间调试长命令通常很困难。将命令包装在脚本中可以使它们更容易调试。如果某些内容不起作用,您可以使用docker exec -it bash -l命令连接到容器并像平常一样调试脚本。

在 Dockerfile 中收集静态文件

静态文件是支持我们网站的 CSS、JavaScript 和图像。静态文件可能并非总是由我们创建。一些静态文件来自安装的 Django 应用程序(例如 Django 管理)。让我们更新我们的Dockerfile以收集静态文件:

# collect the static files
RUN bash /mymdb/scripts/collect_static.sh /mymdb

再次,我们将命令包装在脚本中。让我们将以下脚本添加到scripts/collect_static.sh中:

#!/usr/bin/env bash

root=$1
source $root/venv/bin/activate

export DJANGO_CACHE_TIMEOUT=100
export DJANGO_SECRET_KEY=FAKE_KEY
export DJANGO_SETTINGS_MODULE=config.production_settings

cd $root/django/

python manage.py collectstatic

上述脚本激活了我们在前面的代码中创建的虚拟环境,并设置了所需的环境变量。在这种情况下,大多数这些值都不重要,只要变量存在即可。但是,DJANGO_SETTINGS_MODULE环境变量非常重要。DJANGO_SETTINGS_MODULE环境变量用于 Django 查找设置模块。如果我们不设置它并且没有config/settings.py,那么 Django 将无法启动(甚至manage.py命令也会失败)。

将 Nginx 添加到 Dockerfile

要配置 Nginx,我们将添加一个配置文件和一个 runit 服务脚本:

COPY nginx/mymdb.conf /etc/nginx/sites-available/mymdb.conf
RUN rm /etc/nginx/sites-enabled/*
RUN ln -s /etc/nginx/sites-available/mymdb.conf /etc/nginx/sites-enabled/mymdb.conf

COPY runit/nginx /etc/service/nginx
RUN chmod +x /etc/service/nginx/run

配置 Nginx

让我们将一个 Nginx 配置文件添加到nginx/mymdb.conf中:

# the upstream component nginx needs
# to connect to
upstream django {
    server 127.0.0.1:3031;
}

# configuration of the server
server {

    # listen on all IPs on port 80
    server_name 0.0.0.0;
    listen      80;
    charset     utf-8;

    # max upload size
    client_max_body_size 2M;

    location /static {
        alias /mymdb/django/gathered_static_files;
    }

    location / {
        uwsgi_pass  django;
        include     /etc/nginx/uwsgi_params;
    }

}

Nginx 将负责以下两件事:

  • 提供静态文件(以/static开头的 URL)

  • 将所有其他请求传递给 uWSGI

upstream块描述了我们 Django(uWSGI)服务器的位置。在location /块中,nginx 被指示使用 uWSGI 协议将请求传递给上游服务器。include /etc/nginx/uwsgi_params文件描述了如何映射标头,以便 uWSGI 理解它们。

client_max_body_size是一个重要的设置。它描述了文件上传的最大大小。将这个值设置得太大可能会暴露漏洞,因为攻击者可以用巨大的请求压倒服务器。

创建 Nginx runit 服务

为了让runit知道如何启动 Nginx,我们需要提供一个run脚本。我们的Dockerfile希望它在runit/nginx/run中:

#!/usr/bin/env bash

exec /usr/sbin/nginx \
    -c /etc/nginx/nginx.conf \
    -g "daemon off;"

runit不希望其服务分叉出一个单独的进程,因此我们使用daemon off来运行 Nginx。此外,runit希望我们使用exec来替换我们脚本的进程,新的 Nginx 进程。

将 uWSGI 添加到 Dockerfile

我们使用 uWSGI,因为它通常被评为最快的 WSGI 应用服务器。让我们通过添加以下代码到我们的Dockerfile中来设置它:

# configure uwsgi
COPY uwsgi/mymdb.ini /etc/uwsgi/apps-enabled/mymdb.ini
RUN mkdir -p /var/log/uwsgi/
RUN touch /var/log/uwsgi/mymdb.log
RUN chown www-data /var/log/uwsgi/mymdb.log
RUN chown www-data /var/log/mymdb/mymdb.log

COPY runit/uwsgi /etc/service/uwsgi
RUN chmod +x /etc/service/uwsgi/run

这指示 Docker 使用mymdb.ini文件配置 uWSGI,创建日志目录,并添加 uWSGI runit 服务。为了让 runit 启动 uWSGI 服务,我们使用chmod命令给予 runit 脚本执行权限。

配置 uWSGI 运行 MyMDB

让我们在uwsgi/mymdb.ini中创建 uWSGI 配置:

[uwsgi]
socket = 127.0.0.1:3031
chdir = /mymdb/django/
virtualenv = /mymdb/venv
wsgi-file = config/wsgi.py
env = DJANGO_SECRET_KEY=$(DJANGO_SECRET_KEY)
env = DJANGO_LOG_LEVEL=$(DJANGO_LOG_LEVEL)
env = DJANGO_ALLOWED_HOSTS=$(DJANGO_ALLOWED_HOSTS)
env = DJANGO_DB_NAME=$(DJANGO_DB_NAME)
env = DJANGO_DB_USER=$(DJANGO_DB_USER)
env = DJANGO_DB_PASSWORD=$(DJANGO_DB_PASSWORD)
env = DJANGO_DB_HOST=$(DJANGO_DB_HOST)
env = DJANGO_DB_PORT=$(DJANGO_DB_PORT)
env = DJANGO_CACHE_TIMEOUT=$(DJANGO_CACHE_TIMEOUT)
env = AWS_ACCESS_KEY_ID=$(AWS_ACCESS_KEY_ID)
env = AWS_SECRET_ACCESS_KEY_ID=$(AWS_SECRET_ACCESS_KEY_ID)
env = DJANGO_UPLOAD_S3_BUCKET=$(DJANGO_UPLOAD_S3_BUCKET)
env = DJANGO_LOG_FILE=$(DJANGO_LOG_FILE)
processes = 4
threads = 4

让我们更仔细地看一下其中一些设置:

  • socket告诉 uWSGI 在127.0.0.1:3031上使用其自定义的uwsgi协议打开一个套接字(令人困惑的是,协议和服务器的名称相同)。

  • chdir改变了进程的工作目录。所有路径都需要相对于这个位置。

  • virtualenv告诉 uWSGI 项目虚拟环境的路径。

  • 每个env指令为我们的进程设置一个环境变量。我们可以在我们的代码中使用os.getenv()访问这些变量(例如,production_settings.py)。

  • $(...)是从 uWSGI 进程自己的环境中引用的环境变量(例如,$(DJANGO_SECRET_KEY ))。

  • proccesses设置我们应该运行多少个进程。

  • threads设置每个进程应该有多少线程。

processesthreads设置将根据生产性能进行微调。

创建 uWSGI runit 服务

为了让 runit 知道如何启动 uWSGI,我们需要提供一个run脚本。我们的Dockerfile希望它在runit/uwsgi/run中。这个脚本比我们用于 Nginx 的要复杂:

#!/usr/bin/env bash

source /mymdb/venv/bin/activate

export PGPASSWORD="$DJANGO_DB_PASSWORD"
psql \
    -h "$DJANGO_DB_HOST" \
    -p "$DJANGO_DB_PORT" \
    -U "$DJANGO_DB_USER" \
    -d "$DJANGO_DB_NAME"

if [[ $? != 0 ]]; then
    echo "no db server"
    exit 1
fi

pushd /mymdb/django

python manage.py migrate

if [[ $? != 0 ]]; then
    echo "can't migrate"
    exit 2
fi
popd

exec /sbin/setuser www-data \
    uwsgi \
    --ini /etc/uwsgi/apps-enabled/mymdb.ini \
    >> /var/log/uwsgi/mymdb.log \
    2>&1

这个脚本做了以下三件事:

  • 检查是否可以连接到数据库,否则退出

  • 运行所有迁移或失败时退出

  • 启动 uWSGI

runit 要求我们使用exec来启动我们的进程,以便 uWSGI 将替换run脚本的进程。

完成我们的 Dockerfile

作为最后一步,我们将清理并记录我们正在使用的端口:

RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

EXPOSE 80

EXPOSE语句记录了我们正在使用的端口。重要的是,它实际上并不打开任何端口。当我们运行容器时,我们将不得不这样做。

接下来,让我们为我们的数据库创建一个容器。

创建数据库容器

我们需要一个数据库来在生产中运行 Django。PostgreSQL Docker 社区为我们提供了一个非常强大的 Postgres 镜像,我们可以扩展使用。

让我们在docker/psql/Dockerfile中为我们的数据库创建另一个容器:

FROM postgres:10.1

ADD make_database.sh /docker-entrypoint-initdb.d/make_database.sh

这个Dockerfile的基本镜像将使用 Postgres 10.1。它还有一个方便的设施,它将执行/docker-entrypoint-initdb.d中的任何 shell 或 SQL 脚本作为 DB 初始化的一部分。我们将利用这一点来创建我们的 MyMDB 数据库和用户。

让我们在docker/psql/make_database.sh中创建我们的数据库初始化脚本:

#!/usr/bin/env bash

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
    CREATE DATABASE $DJANGO_DB_NAME;
    CREATE USER $DJANGO_DB_USER;
    GRANT ALL ON DATABASE $DJANGO_DB_NAME TO "$DJANGO_DB_USER";
    ALTER USER $DJANGO_DB_USER PASSWORD '$DJANGO_DB_PASSWORD';
    ALTER USER $DJANGO_DB_USER CREATEDB;
EOSQL

我们在前面的代码中使用了一个 shell 脚本,以便我们可以使用环境变量来填充我们的 SQL。

现在我们的两个容器都准备好了,让我们确保我们实际上可以通过注册并配置 AWS 来启动它们。

在 AWS S3 上存储上传的文件

我们期望我们的 MyMDB 将文件保存到 S3。为了实现这一点,我们需要注册 AWS,然后配置我们的 shell 以便能够使用 AWS。

注册 AWS

要注册,请转到aws.amazon.com并按照其说明操作。请注意,注册是免费的。

我们将使用的资源在撰写本书时都在 AWS 免费层中。免费层的一些元素仅在第一年对新帐户可用。在执行任何 AWS 命令之前,请检查您的帐户的资格。

设置 AWS 环境

为了与 AWS API 交互,我们将需要以下两个令牌——访问密钥和秘密访问密钥。这对密钥定义了对帐户的访问。

要生成一对令牌,转到console.aws.amazon.com/iam/home?region=us-west-2#/security_credential_,单击访问密钥,然后单击创建新的访问密钥按钮。如果您丢失了秘密访问密钥,将无法检索它,因此请确保将其保存在安全的地方。

上述的 AWS 控制台链接将为您的根帐户生成令牌。在我们测试时这没问题。将来,您应该使用 AWS IAM 权限系统创建具有有限权限的用户。

接下来,让我们安装 AWS 命令行界面(CLI):

$ pip install awscli

然后,我们需要使用我们的密钥和区域配置 AWS 命令行工具。aws命令提供一个交互式configure子命令来执行此操作。让我们在命令行上运行它:

$ aws configure
 AWS Access Key ID [None]: <Your ACCESS key>
 AWS Secret Access Key [None]: <Your secret key>
 Default region name [None]: us-west-2
 Default output format [None]: json

aws configure命令将存储您在家目录中的.aws目录中输入的值。

要确认您的新帐户是否设置正确,请请求 EC2 实例的列表(不应该有):

$ aws ec2 describe-instances
{
    "Reservations": []
}

创建文件上传存储桶

S3 被组织成存储桶。每个存储桶必须有一个唯一的名称(在整个 AWS 中唯一)。每个存储桶还将有一个控制访问的策略。

通过执行以下命令来创建我们的文件上传存储桶(将BUCKET_NAME更改为您自己的唯一名称):

$ export AWS_ACCESS_KEY=#your value
$ export AWS_SECRET_ACCESS_KEY=#yourvalue
$ aws s3 mb s3://BUCKET_NAME

为了让未经身份验证的用户访问我们存储桶中的文件,我们必须设置一个策略。让我们在AWS/mymdb-bucket-policy.json中创建策略:

{
    "Version": "2012-10-17",
    "Id": "mymdb-bucket-policy",
    "Statement": [
        {
            "Sid": "allow-file-download-stmt",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::BUCKET_NAME/*"
        }
    ]
}

确保将BUCKET_NAME更新为您的存储桶的名称。

现在,我们可以使用 AWS CLI 在您的存储桶上应用策略:

$ aws s3api put-bucket-policy --bucket BUCKET_NAME --policy "$(cat AWS/mymdb-bucket-policy.json)"

确保您记住您的存储桶名称,AWS 访问密钥和 AWS 秘密访问密钥,因为我们将在下一节中使用它们。

使用 Docker Compose

我们现在已经准备好生产部署的所有部分。 Docker Compose 是 Docker 让多个容器一起工作的方式。 Docker Compose 由一个命令行工具docker-compose,一个配置文件docker-compose.yml和一个环境变量文件.env组成。我们将在项目目录的根目录中创建这两个文件。

永远不要将您的.env文件检入版本控制。那里是您的秘密所在。不要让它们泄漏。

首先,让我们在.env中列出我们的环境变量:

# Django settings
DJANGO_SETTINGS_MODULE=config.production_settings
DJANGO_SECRET_KEY=#put your secret key here
DJANGO_LOG_LEVEL=DEBUG
DJANGO_LOG_FILE=/var/log/mymdb/mymdb.log
DJANGO_ALLOWED_HOSTS=# put your domain here
DJANGO_DB_NAME=mymdb
DJANGO_DB_USER=mymdb
DJANGO_DB_PASSWORD=#put your password here
DJANGO_DB_HOST=db
DJANGO_DB_PORT=5432
DJANGO_CACHE_TIMEOUT=200

AWS_ACCESS_KEY_ID=# put aws key here
AWS_SECRET_ACCESS_KEY_ID=# put your secret key here
DJANGO_UPLOAD_S3_BUCKET=# put BUCKET_NAME here

# Postgres settings
POSTGRES_PASSWORD=# put your postgress admin password here

这些值中的许多值都可以硬编码,但有一些值需要为您的项目设置:

  • DJANGO_SECRET_KEY:Django 秘密密钥用作 Django 加密种子的一部分

  • DJANGO_DB_PASSWORD:这是 Django 的 MyMDB 数据库用户的密码

  • AWS_ACCESS_KEY_ID:您的 AWS 访问密钥

  • AWS_SECRET_ACCESS_KEY_ID:您的 AWS 秘密访问密钥

  • DJANGO_UPLOAD_S3_BUCKET:您的存储桶名称

  • POSTGRES_PASSWORD:Postgres 数据库超级用户的密码(与 MyMDB 数据库用户不同)

  • DJANGO_ALLOWED_HOSTS:我们将提供服务的域(一旦我们启动 EC2 实例,我们将填写这个)

接下来,我们在docker-compose.yml中定义我们的容器如何一起工作:

version: '3'

services:
  db:
    build: docker/psql
    restart: always
    ports:
      - "5432:5432"
    environment:
      - DJANGO_DB_USER
      - DJANGO_DB_NAME
      - DJANGO_DB_PASSWORD
  web:
    build: .
    restart: always
    ports:
      - "80:80"
    depends_on:
      - db
    environment:
      - DJANGO_SETTINGS_MODULE
      - DJANGO_SECRET_KEY
      - DJANGO_LOG_LEVEL
      - DJANGO_LOG_FILE
      - DJANGO_ALLOWED_HOSTS
      - DJANGO_DB_NAME
      - DJANGO_DB_USER
      - DJANGO_DB_PASSWORD
      - DJANGO_DB_HOST
      - DJANGO_DB_PORT
      - DJANGO_CACHE_TIMEOUT
      - AWS_ACCESS_KEY_ID
      - AWS_SECRET_ACCESS_KEY_ID
      - DJANGO_UPLOAD_S3_BUCKET

此 Compose 文件描述了构成 MyMDB 的两个服务(dbweb)。让我们回顾一下我们使用的配置选项:

  • build:构建上下文的路径。一般来说,构建上下文是一个带有Dockerfile的目录。因此,db使用psql目录,web使用.目录(项目根目录,其中有一个Dockerfile)。

  • ports:端口映射列表,描述如何将主机端口上的连接路由到容器上的端口。在我们的情况下,我们不会更改任何端口。

  • environment:每个服务的环境变量。我们使用的格式意味着我们从我们的.env文件中获取值。但是,您也可以使用MYVAR=123语法硬编码值。

  • restart:这是容器的重启策略。always表示如果容器因任何原因停止,Docker 应该始终尝试重新启动容器。

  • depends_on:这告诉 Docker 在启动web容器之前启动db容器。然而,我们仍然不能确定 Postgres 是否能在 uWSGI 之前成功启动,因此我们需要在我们的 runit 脚本中检查数据库是否已经启动。

跟踪环境变量

我们的生产配置严重依赖于环境变量。让我们回顾一下在 Django 中使用os.getenv()之前必须遵循的步骤:

  1. .env中列出变量

  2. docker-compose.yml中的environment选项下包括变量

  3. env中包括 uWSGI ini 文件变量

  4. 使用os.getenv访问变量

在本地运行 Docker Compose

现在我们已经配置了我们的 Docker 容器和 Docker Compose,我们可以运行这些容器。Docker Compose 的一个优点是它可以在任何地方提供相同的环境。这意味着我们可以在本地运行 Docker Compose,并获得与我们在生产环境中获得的完全相同的环境。不必担心在不同环境中有额外的进程或不同的分发。让我们在本地运行 Docker Compose。

安装 Docker

要继续阅读本章的其余部分,您必须在您的机器上安装 Docker。Docker, Inc.提供免费的 Docker 社区版,可以从其网站上获得:docker.com。Docker 社区版安装程序在 Windows 和 Mac 上是一个易于使用的向导。Docker, Inc.还为大多数主要的 Linux 发行版提供官方软件包。

安装完成后,您将能够按照接下来的所有步骤进行操作。

使用 Docker Compose

要在本地启动我们的容器,请运行以下命令:

$ docker-compose up -d 

docker-compose up构建然后启动我们的容器。-d选项将 Compose 与我们的 shell 分离。

要检查我们的容器是否正在运行,我们可以使用docker ps

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                          NAMES
0bd7f7203ea0        mymdb_web           "/sbin/my_init"          52 seconds ago      Up 51 seconds       0.0.0.0:80->80/tcp, 8031/tcp   mymdb_web_1
3b9ecdcf1031        mymdb_db            "docker-entrypoint..."   46 hours ago        Up 52 seconds       0.0.0.0:5432->5432/tcp         mymdb_db_1

要检查 Docker 日志,您可以使用docker logs命令来记录启动脚本的输出:

$ docker logs mymdb_web_1

要访问容器内部的 shell(以便您可以检查文件或查看应用程序日志),请使用此docker exec命令启动 bash:

$ docker exec -it mymdb_web_1 bash -l

要停止容器,请使用以下命令:

$ docker-compose stop

要停止容器并删除它们,请使用以下命令:

$ docker-compose down

当您删除一个容器时,您会删除其中的所有数据。对于 Django 容器来说这不是问题,因为它不保存数据。然而,如果您删除 db 容器,您将丢失数据库的数据。在生产环境中要小心。

通过容器注册表共享您的容器

现在我们有一个可工作的容器,我们可能希望使其更广泛地可访问。Docker 有一个容器注册表的概念。您可以将您的容器推送到容器注册表,以便将其公开或仅提供给您的团队。

最受欢迎的 Docker 容器注册表是 Docker Hub(hub.docker.com)。您可以免费创建一个帐户,并且在撰写本书时,每个帐户都附带一个免费的私有存储库和无限的公共存储库。大多数云提供商也提供 docker 存储库托管设施(尽管价格可能有所不同)。

本节的其余部分假设您已配置了主机。我们将以 Docker Hub 为例,但无论谁托管您的容器存储库,所有步骤都是相同的。

要共享您的容器,您需要做以下事情:

  1. 登录到 Docker 注册表

  2. 标记我们的容器

  3. 推送到 Docker 注册表

让我们首先登录到 Docker 注册表:

$ docker login -u USERNAME -p PASSWORD docker.io

USERNAMEPASSWORD 的值需要与您在 Docker Hub 帐户上使用的相同。 docker.io 是 Docker Hub 容器注册表的域。如果您使用不同的容器注册表主机,则需要更改域。

现在我们已经登录,让我们重新构建并标记我们的容器:

$ docker build . -t USERNAME/REPOSITORY:latest

其中 USERNAMEREPOSITORY 的值将被替换为您的值。 :latest 后缀是构建的标签。我们可以在同一个存储库中有许多不同的标签(例如 developmentstable1.x)。Docker 中的标签很像版本控制中的标签;它们帮助我们快速轻松地找到特定的项目。 :latest 是给最新构建的常见标签(尽管它可能不稳定)。

最后,让我们将标记的构建推送到我们的存储库:

$ docker push USERNAME/REPOSITORY:latest

Docker 将显示其上传的进度,然后在成功时显示 SHA256 摘要。

当我们将 Docker 镜像推送到远程存储库时,我们需要注意镜像中存储的任何私人数据。我们在 Dockerfile 中创建或添加的所有文件都包含在推送的镜像中。就像我们不希望在存储在远程存储库中的代码中硬编码密码一样,我们也不希望在可能存储在远程服务器上的 Docker 镜像中存储敏感数据(如密码)。这是我们强调将密码存储在环境变量而不是硬编码它们的另一个原因。

太好了!现在你可以与其他团队成员分享存储库,以运行你的 Docker 容器。

接下来,让我们启动我们的容器。

在云中的 Linux 服务器上启动容器

现在我们已经让一切运转起来,我们可以将其部署到互联网上。我们可以使用 Docker 将我们的容器部署到任何 Linux 服务器上。大多数使用 Docker 的人都在使用云提供商来提供 Linux 服务器主机。在我们的情况下,我们将使用 AWS。

在前面的部分中,当我们使用 docker-compose 时,实际上是在向运行在我们的机器上的 Docker 服务发送命令。Docker Machine 提供了一种管理运行 Docker 的远程服务器的方法。我们将使用 docker-machine 来启动一个 EC2 实例,该实例将托管我们的 Docker 容器。

启动 EC2 实例可能会产生费用。在撰写本书时,我们将使用符合 AWS 免费套餐资格的实例 t2.micro。但是,您有责任检查 AWS 免费套餐的条款。

启动 Docker EC2 VM

我们将在我们的帐户的虚拟私有云VPC)中启动我们的 EC2 VM(称为 EC2 实例)。但是,每个帐户都有一个唯一的 VPC ID。要获取您的 VPC ID,请运行以下命令:

$ export AWS_ACCESS_KEY=#your value
$ export AWS_SECRET_ACCESS_KEY=#yourvalue
$ export AWS_DEFAULT_REGION=us-west-2
$ aws ec2 describe-vpcs | grep VpcId
            "VpcId": "vpc-a1b2c3d4",

上述代码中使用的值不是真实值。

现在我们知道我们的 VPC ID,我们可以使用 docker-machine 来启动一个 EC2 实例:

$ docker-machine create \
     --driver amazonec2 \
     --amazonec2-instance-type t2.micro \
     --amazonec2-vpc-id vpc-a1b2c3d4 \
     --amazonec2-region us-west-2 \
     mymdb-host

这告诉 Docker Machine 在us-west-2地区和提供的 VPC 中启动一个 EC2 t2.micro实例。Docker Machine 负责确保服务器上安装并启动了 Docker 守护程序。在 Docker Machine 中引用此 EC2 实例时,我们使用名称 mymdb-host

当实例启动时,我们可以向 AWS 请求我们实例的公共 DNS 名称:

$ aws ec2 describe-instances | grep -i publicDnsName

即使只有一个实例运行,上述命令可能会返回相同值的多个副本。将结果放入 .env 文件中作为 DJANGO_ALLOWED_HOSTS

所有 EC2 实例都受其安全组确定的防火墙保护。Docker Machine 在启动我们的实例时自动为我们的服务器创建了一个安全组。为了使我们的 HTTP 请求到达我们的机器,我们需要在 docker-machine 安全组中打开端口 80,如下所示:

$ aws ec2 authorize-security-group-ingress \
    --group-name docker-machine \
    --protocol tcp \
    --port 80 \
    --cidr 0.0.0.0/0

现在一切都设置好了,我们可以配置docker-compose与我们的远程服务器通信,并启动我们的容器:

$ eval $(docker-machine env mymdb-host)
$ docker-compose up -d

恭喜!MyMDB 已经在生产环境中运行起来了。通过导航到DJANGO_ALLOWED_HOSTS中使用的地址来查看它。

这里的说明重点是启动 AWS Linux 服务器。然而,所有的 Docker 命令都有等效的选项适用于 Google Cloud、Azure 和其他主要的云服务提供商。甚至还有一个通用选项,可以与任何 Linux 服务器配合使用,尽管根据 Linux 发行版和 Docker 版本的不同,效果可能有所不同。

关闭 Docker EC2 虚拟机

Docker Machine 也可以用于停止运行 Docker 的虚拟机,如下面的代码片段所示:

$ export AWS_ACCESS_KEY=#your value
$ export AWS_SECRET_ACCESS_KEY=#yourvalue
$ export AWS_DEFAULT_REGION=us-west-2
$ eval $(docker-machine env mymdb-host)
$ docker-machine stop mymdb-host 

这将停止 EC2 实例并销毁其中的所有容器。如果您希望保留您的数据库,请确保通过运行前面的eval命令来备份您的数据库,然后使用docker exec -it mymdb_db_1 bash -l打开一个 shell。

总结

在这一章中,我们已经将 MyMDB 部署到了互联网上的生产 Docker 环境中。我们使用 Dockerfile 为 MyMDB 创建了一个 Docker 容器。我们使用 Docker Compose 使 MyMDB 与 PostgreSQL 数据库(也在 Docker 容器中)配合工作。最后,我们使用 Docker Machine 在 AWS 云上启动了这些容器。

恭喜!你现在已经让 MyMDB 运行起来了。

在下一章中,我们将实现 Stack Overflow。

第六章:开始 Answerly

我们将构建的第二个项目是一个名为 Answerly 的 Stack Overflow 克隆。 注册 Answerly 的用户将能够提问和回答问题。 提问者还将能够接受答案以标记它们为有用。

在本章中,我们将做以下事情:

  • 创建我们的新 Django 项目 Answerly,一个 Stack Overflow 克隆

  • 为 Answerly 创建模型(QuestionAnswer

  • 让用户注册

  • 创建表单,视图和模板,让用户与我们的模型进行交互

  • 运行我们的代码

该项目的代码可在github.com/tomaratyn/Answerly上找到。

本章不会深入讨论已在第一章中涵盖的主题,尽管它将涉及许多相同的要点。 相反,本章将重点放在更进一步并引入新视图和第三方库上。

让我们开始我们的项目!

创建 Answerly Django 项目

首先,让我们为我们的项目创建一个目录:

$ mkdir answerly
$ cd answerly

我们未来的所有命令和路径都将相对于这个项目目录。 一个 Django 项目由多个 Django 应用程序组成。

我们将使用pip安装 Django,Python 的首选软件包管理器。 我们还将在requirements.txt文件中跟踪我们安装的软件包:

django<2.1
psycopg2<2.8

现在,让我们安装软件包:

$ pip install -r requirements.txt

接下来,让我们使用django-admin生成实际的 Django 项目:

$ django-admin startproject config
$ mv config django

默认情况下,Django 创建一个将使用 SQLite 的项目,但这对于生产来说是不可用的; 因此,我们将遵循在开发和生产中使用相同数据库的最佳实践。

让我们打开django/config/settings.py并更新它以使用我们的 Postgres 服务器。 找到以DATABASES开头的settings.py中的行; 要使用 Postgres,请将DATABASES的值更改为以下代码:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'answerly',
        'USER': 'answerly',
        'PASSWORD': 'development',
        'HOST': '127.0.0.1',
        'PORT': '5432',
    }
}

现在我们已经开始并配置了我们的项目,我们可以创建并安装我们将作为项目一部分制作的两个 Django 应用程序:

$ cd django
$ python manage.py startapp user
$ python manage.py startapp qanda

Django 项目由应用程序组成。 Django 应用程序是所有功能和代码所在的地方。 模型,表单和模板都属于 Django 应用程序。 应用程序,就像其他 Python 模块一样,应该有一个明确定义的范围。 在我们的情况下,我们有两个应用程序,每个应用程序都有不同的角色。 qanda应用程序将负责我们应用程序的问题和答案功能。 user应用程序将负责我们应用程序的用户管理。 它们每个都将依赖其他应用程序和 Django 的核心功能以有效地工作。

现在,让我们通过更新django/config/settings.py在我们的项目中安装我们的应用程序:

INSTALLED_APPS = [
    'user',
    'qanda',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

既然 Django 知道我们的应用程序,让我们从qanda的模型开始安装。

创建 Answerly 模型

Django 在创建数据驱动的应用程序方面特别有帮助。 模型代表应用程序中的数据,通常是这些应用程序的核心。 Django 通过fat models, thin views, dumb templates的最佳实践鼓励这一点。 这些建议鼓励我们将业务逻辑放在我们的模型中,而不是我们的视图中。

让我们从Question模型开始构建我们的qanda模型。

创建 Question 模型

我们将在django/qanda/models.py中创建我们的Question模型:

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

class Question(models.Model):
    title = models.CharField(max_length=140)
    question = models.TextField()
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('questions:question_detail', kwargs={'pk': self.id})

    def can_accept_answers(self, user):
        return user == self.user

Question模型,像所有 Django 模型一样,派生自django.db.models.Model。 它具有以下四个字段,这些字段将成为questions_question表中的列:

  • title:一个字符字段,将成为最多 140 个字符的VARCHAR列。

  • question:这是问题的主体。 由于我们无法预测这将有多长,我们使用TextField,它将成为TEXT列。TEXT列没有大小限制。

  • user:这将创建一个外键到项目配置的用户模型。 在我们的情况下,我们将使用 Django 提供的默认django.contrib.auth.models.User。 但是,建议我们尽量避免硬编码这一点。

  • created:这将自动设置为创建Question模型的日期和时间。

Question还实现了 Django 模型上常见的两种方法(__str__get_absolute_url):

  • __str__():这告诉 Python 如何将我们的模型转换为字符串。这在管理后端、我们自己的模板和调试中非常有用。

  • get_absolute_url():这是一个常见的实现方法,让模型返回查看此模型的 URL 路径。并非所有模型都需要此方法。Django 的内置视图,如CreateView,将使用此方法在创建模型后将用户重定向到视图。

最后,在“fat models”的精神下,我们还有can_accept_answers()。谁可以接受对QuestionAnswer的决定取决于Question。目前,只有提问问题的用户可以接受答案。

现在我们有了Question,自然需要Answer

创建Answer模型

我们将在django/questions/models.py文件中创建Answer模型,如下所示:

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

class Question(model.Models):
    # skipped

class Answer(models.Model):
    answer = models.TextField()
    user = models.ForeignKey(to=settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)
    question = models.ForeignKey(to=Question,
                                 on_delete=models.CASCADE)
    accepted = models.BooleanField(default=False)

    class Meta:
        ordering = ('-created', )

Answer模型有五个字段和一个Meta类。让我们先看看这些字段:

  • answer:这是用户答案的无限文本字段。answer将成为一个TEXT列。

  • user:这将创建一个到我们项目配置为使用的用户模型的外键。用户模型将获得一个名为answer_set的新RelatedManager,它将能够查询用户的所有Answer

  • question:这将创建一个到我们的Question模型的外键。Question还将获得一个名为answer_set的新RelatedManager,它将能够查询所有QuestionAnswer

  • created:这将设置为创建Answer的日期和时间。

  • accepted:这是一个默认设置为False的布尔值。我们将用它来标记已接受的答案。

模型的Meta类让我们为我们的模型和表设置元数据。对于Answer,我们使用ordering选项来确保所有查询都将按created的降序排序。通过这种方式,我们确保最新的答案将首先列出,默认情况下。

现在我们有了QuestionAnswer模型,我们需要创建迁移以在数据库中创建它们的表。

创建迁移

Django 自带一个内置的迁移库。这是 Django“一揽子”哲学的一部分。迁移提供了一种管理我们需要对模式进行的更改的方法。每当我们对模型进行更改时,我们可以使用 Django 生成一个迁移,其中包含了如何创建或更改模式以适应新模型定义的指令。要对数据库进行更改,我们将应用模式。

与我们在项目上执行的许多操作一样,我们将使用 Django 为我们的项目提供的manage.py脚本:

$ python manage.py makemigrations
 Migrations for 'qanda':
  qanda/migrations/0001_initial.py
    - Create model Answer
    - Create model Question
    - Add field question to answer
    - Add field user to answer
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, qanda, sessions
Running migrations:
  Applying qanda.0001_initial... OK

现在我们已经创建了迁移并应用了它们,让我们为我们的项目设置一个基础模板,以便我们的代码能够正常工作。

添加基础模板

在创建视图之前,让我们创建一个基础模板。Django 的模板语言允许模板相互继承。基础模板是所有其他项目模板都将扩展的模板。这将给我们整个项目一个共同的外观和感觉。

由于项目由多个应用程序组成,它们都将使用相同的基础模板,因此基础模板属于项目,而不属于任何特定的应用程序。这是一个罕见的例外,违反了一切都在应用程序中的规则。

要添加一个项目范围的模板目录,请更新django/config/settings.py。检查TEMPLATES设置并将其更新为以下内容:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates')
        ],
        'APP_DIRS': True,
        'OPTIONS': {
                # skipping rest of options.
        },
    },
]

特别是,django.template.backends.django.DjangoTemplates设置的DIRS选项设置了一个项目范围的模板目录,将被搜索。'APP_DIRS': True意味着每个安装的应用程序的templates目录也将被搜索。为了让 Django 搜索django/templates,我们必须将os.path.join(BASE_DIR, 'templates')添加到DIRS列表中。

创建 base.html

Django 自带了自己的模板语言,名为 Django 模板语言。Django 模板是文本文件,使用字典(称为上下文)进行渲染以查找值。模板还可以包括标签(使用{% tag argument %}语法)。模板可以使用{{ variableName }}语法从其上下文中打印值。值可以发送到过滤器进行调整,然后显示(例如,{{ user.username | uppercase }}将打印用户的用户名,所有字符都是大写)。最后,{# ignored #}语法可以注释掉多行文本。

我们将在django/templates/base.html中创建我们的基本模板:

{% load static %}
<!DOCTYPE html>
<html lang="en" >
<head >
  <meta charset="UTF-8" >
  <title >{% block title %}Answerly{% endblock %}</title >
  <link
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css"
      rel="stylesheet">
  <link
      href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
      rel="stylesheet">
  <link rel="stylesheet" href="{% static "base.css" %}" >
</head >
<body >
<nav class="navbar navbar-expand-lg  bg-light" >
  <div class="container" >
    <a class="navbar-brand" href="/" >Answerly</a >
    <ul class="navbar-nav" >
    </ul >
  </div >
</nav >
<div class="container" >
  {% block body %}{% endblock %}
</div >
</body >
</html >

我们不会详细介绍这个 HTML,但值得回顾涉及的 Django 模板标签:

  • {% load static %}load让我们加载默认情况下不可用的模板标签库。在这种情况下,我们加载了静态库,它提供了static标签。该库和标签并不总是共享它们的名称。这是由django.contrib.static应用程序提供的 Django。

  • {% block title %}Answerly{% endblock %}:块让我们定义模板在扩展此模板时可以覆盖的区域。

  • {% static 'base.css' %}static标签(从前面加载的static库中加载)使用STATIC_URL设置来创建对静态文件的引用。在这种情况下,它将返回/static/base.css。只要文件在settings.STATICFILES_DIRS列出的目录中,并且 Django 处于调试模式,Django 就会为我们提供该文件。对于生产环境,请参阅第九章,部署 Answerly

这就足够我们的base.html文件开始了。我们将在更新 base.html 导航部分中稍后更新base.html中的导航。

接下来,让我们配置 Django 知道如何找到我们的base.css文件,通过配置静态文件。

配置静态文件

接下来,让我们在django/config/settings.py中配置一个项目范围的静态文件目录:

STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static'),
]

这将告诉 Django,在调试模式下应该提供django/static/中的任何文件。对于生产环境,请参阅第九章,部署 Answerly

让我们在django/static/base.css中放一些基本的 CSS:

nav.navbar {
  margin-bottom: 1em;
}

现在我们已经创建了基础,让我们创建AskQuestionView

让用户发布问题

现在我们将创建一个视图,让用户发布他们需要回答的问题。

Django 遵循模型-视图-模板MVT)模式,将模型、控制和表示逻辑分开,并鼓励可重用性。模型代表我们将在数据库中存储的数据。视图负责处理请求并返回响应。视图不应该包含 HTML。模板负责响应的主体和定义 HTML。这种责任的分离已被证明使编写代码变得容易。

为了让用户发布问题,我们将执行以下步骤:

  1. 创建一个处理问题的表单

  2. 创建一个使用 Django 表单创建问题的视图

  3. 创建一个在 HTML 中渲染表单的模板

  4. 在视图中添加一个path

首先,让我们创建QuestionForm类。

提问表单

Django 表单有两个目的。它们使得渲染表单主体以接收用户输入变得容易。它们还验证用户输入。当一个表单被实例化时,它可以通过intial参数给出初始值,并且通过data参数给出要验证的数据。提供了数据的表单被称为绑定的。

Django 的许多强大之处在于将模型、表单和视图轻松地结合在一起构建功能。

我们将在django/qanda/forms.py中创建我们的表单:

from django import forms
from django.contrib.auth import get_user_model

from qanda.models import Question

class QuestionForm(forms.ModelForm):
    user = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=get_user_model().objects.all(),
        disabled=True,
    )

    class Meta:
        model = Question
        fields = ['title', 'question', 'user', ]

ModelForm使得从 Django 模型创建表单更容易。我们使用QuestionForm的内部Meta类来指定表单的模型和字段。

通过添加一个user字段,我们能够覆盖 Django 如何呈现user字段。我们告诉 Django 使用HiddenInput小部件,它将把字段呈现为<input type='hidden'>queryset参数让我们限制有效值的用户(在我们的情况下,所有用户都是有效的)。最后,disabled参数表示我们将忽略由data(即来自请求的)提供的任何值,并依赖于我们提供给表单的initial值。

现在我们知道如何呈现和验证问题表单,让我们创建我们的视图。

创建 AskQuestionView

我们将在django/qanda/views.py中创建我们的AskQuestionView类:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView

from qanda.forms import QuestionForm
from qanda.models import Question

class AskQuestionView(LoginRequiredMixin, CreateView):
    form_class = QuestionForm
    template_name = 'qanda/ask.html'

    def get_initial(self):
        return {
            'user': self.request.user.id
        }

    def form_valid(self, form):
        action = self.request.POST.get('action')
        if action == 'SAVE':
            # save and redirect as usual.
            return super().form_valid(form)
        elif action == 'PREVIEW':
            preview = Question(
                question=form.cleaned_data['question'],
                title=form.cleaned_data['title'])
            ctx = self.get_context_data(preview=preview)
            return self.render_to_response(context=ctx)
        return HttpResponseBadRequest()

AskQuestionView派生自CreateView并使用LoginRequiredMixinLoginRequiredMixin确保任何未登录用户发出的请求都将被重定向到登录页面。CreateView知道如何为GET请求呈现模板,并在POST请求上验证表单。如果表单有效,CreateView将调用form_valid。如果表单无效,CreateView将重新呈现模板。

我们的form_valid方法覆盖了原始的CreateView方法,以支持保存和预览模式。当我们想要保存时,我们将调用原始的form_valid方法。原始方法保存新问题并返回一个 HTTP 响应,将用户重定向到新问题(使用Question.get_absolute_url())。当我们想要预览问题时,我们将在我们模板的上下文中重新呈现我们的模板,其中包含新的preview变量。

当我们的视图实例化表单时,它将把get_initial()的结果作为initial参数传递,并将POST数据作为data参数传递。

现在我们有了我们的视图,让我们创建ask.html

创建 ask.html

让我们在django/qanda/ask.html中创建我们的模板:

{% extends "base.html" %}

{% load markdownify %}
{% load crispy_forms_tags %}

{% block title %} Ask a question {% endblock %}

{% block body %}
  <div class="col-md-12" >
    <h1 >Ask a question</h1 >
    {% if preview %}
      <div class="card question-preview" >
        <div class="card-header" >
          Question Preview
        </div >
        <div class="card-body" >
          <h1 class="card-title" >{{ preview.title }}</h1>
          {{ preview.question |  markdownify }}
        </div >
      </div >
    {% endif %}

    <form method="post" >
      {{ form | crispy }}
      {% csrf_token %}
      <button class="btn btn-primary" type="submit" name="action"
              value="PREVIEW" >
        Preview
      </button >
      <button class="btn btn-primary" type="submit" name="action"
              value="SAVE" >
        Ask!
      </button >
    </form >
  </div >
{% endblock %}

此模板使用我们的base.html模板,并将所有 HTML 放在那里定义的blocks中。当我们呈现模板时,Django 会呈现base.html,然后用在ask.html中定义的内容填充块的值。

ask.html还加载了两个第三方标签库,markdownifycrispy_forms_tagsmarkdownify提供了用于预览卡正文的markdownify过滤器({{preview.question | markdownify}})。crispy_forms_tags库提供了crispy过滤器,它应用 Bootstrap 4 CSS 类以帮助 Django 表单呈现得很好。

这些库中的每一个都需要安装和配置,我们将在接下来的部分中进行(安装和配置 Markdownify安装和配置 Django Crispy Forms)。

以下是ask.html向我们展示的一些新标记:

  • {% if preview %}:这演示了如何在 Django 模板语言中使用if语句。我们只想在我们的上下文中有一个preview变量时才呈现Question的预览。

  • {% csrf_token %}:此标记将预期的 CSRF 令牌添加到我们的表单中。 CSRF 令牌有助于保护我们免受恶意脚本试图代表一个无辜但已登录的用户提交数据的攻击;有关更多信息,请参阅第三章,海报、头像和安全性。在 Django 中,CSRF 令牌是不可选的,缺少 CSRF 令牌的POST请求将不会被处理。

让我们更仔细地看看那些第三方库,从 Markdownify 开始。

安装和配置 Markdownify

Markdownify 是由 R Moelker 和 Erwin Matijsen 创建的 Django 应用程序,可在Python Package IndexPyPI)上找到,并根据 MIT 许可证(一种流行的开源许可证)进行许可。Markdownify 提供了 Django 模板过滤器markdownify,它将 Markdown 转换为 HTML。

Markdownify 通过使用python-markdown包将 Markdown 转换为 HTML 来工作。然后,Marodwnify 使用 Mozilla 的bleach库来清理结果 HTML,以防止跨站脚本(XSS)攻击。然后将结果返回到模板进行输出。

要安装 Markdownify,让我们将其添加到我们的requirements.txt文件中:

django-markdownify==0.2.2

然后,运行pip进行安装:

$ pip install -r requirements.txt

现在,我们需要在django/config/settings.py中将markdownify添加到我们的INSTALLED_APPS列表中。

最后一步是配置 Markdownify,让它知道要对哪些 HTML 标签进行白名单。将以下设置添加到settings.py中:

MARKDOWNIFY_STRIP = False
MARKDOWNIFY_WHITELIST_TAGS = [
    'a', 'blockquote', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 
    'h7', 'li', 'ol', 'p', 'strong', 'ul',
]

这将使我们的用户可以使用所有文本、列表和标题标签。将MARKDOWNIFY_STRIP设置为False告诉 Markdownify 对其他 HTML 标签进行 HTML 编码(而不是剥离)。

现在我们已经配置了 Markdownify,让我们安装和配置 Django Crispy Forms。

安装和配置 Django Crispy Forms

Django Crispy Forms 是 PyPI 上可用的第三方 Django 应用程序。Miguel Araujo 是开发负责人。它是根据 MIT 许可证许可的。Django Crispy Forms 是最受欢迎的 Django 库之一,因为它使得渲染漂亮(清晰)的表单变得如此容易。

在 Django 中遇到的问题之一是,当 Django 渲染字段时,它会呈现为这样:

<label for="id_title">Title:</label>
<input 
      type="text" name="title" maxlength="140" required id="id_title" />

然而,为了漂亮地设计该表单,例如使用 Bootstrap 4,我们希望呈现类似于这样的内容:

<div class="form-group"> 
<label for="id_title" class="form-control-label  requiredField">
   Title
</label> 
<input type="text" name="title" maxlength="140" 
  class="textinput textInput form-control" required="" id="id_title">  
</div>

遗憾的是,Django 没有提供钩子,让我们轻松地将字段包装在具有类form-groupdiv中,或者添加 CSS 类,如form-controlform-control-label

Django Crispy Forms 通过其crispy过滤器解决了这个问题。如果我们通过执行{{ form | crispy}}将一个表单发送到它,Django Crispy Forms 将正确地转换表单的 HTML 和 CSS,以适应各种 CSS 框架(包括 Zurb Foundation,Bootstrap 3 和 Bootstrap 4)。您可以通过更高级的使用 Django Crispy Forms 进一步自定义表单的渲染,但在本章中我们不会这样做。

要安装 Django Crispy Forms,让我们将其添加到我们的requirements.txt并使用pip进行安装:

$ echo "django-crispy-forms==1.7.0" >> requirements.txt
$ pip install -r requirements.txt

现在,我们需要通过编辑django/config/settings.py并将'crispy_forms'添加到我们的INSTALLED_APPS列表中,将其安装为我们项目中的 Django 应用程序。

接下来,我们需要配置我们的项目,以便 Django Crispy Forms 知道使用 Bootstrap 4 模板包。更新django/config/settings.py以进行新的配置:

CRISPY_TEMPLATE_PACK = 'bootstrap4'

现在我们已经安装了模板所依赖的所有库,我们可以配置 Django 将请求路由到我们的AskQuestionView

将请求路由到 AskQuestionView

Django 使用 URLConf 路由请求。这是一个path()对象的列表,用于匹配请求的路径。第一个匹配的path()的视图将处理请求。URLConf 可以包含另一个 URLConf。项目的设置定义了其根 URLConf(在我们的情况下是django/config/urls.py)。

在根 URLConf 中为项目中所有视图的所有path()对象定义可以变得混乱,并使应用程序不太可重用。通常方便的做法是在每个应用程序中放置一个 URLConf(通常在urls.py文件中)。然后,根 URLConf 可以使用include()函数来包含其他应用程序的 URLConfs 以路由请求。

让我们在django/qanda/urls.py中为我们的qanda应用程序创建一个 URLConf:

from django.urls.conf import path

from qanda import views

app_name = 'qanda'
urlpatterns = [
    path('ask', views.AskQuestionView.as_view(), name='ask'),
]

路径至少有两个组件:

  • 首先,是定义匹配路径的字符串。这可能有命名参数,将传递给视图。稍后我们将在将请求路由到 QuestionDetail 视图部分看到一个例子。

  • 其次,是一个接受请求并返回响应的可调用对象。如果您的视图是一个函数(也称为基于函数的视图FBV)),那么您可以直接传递对函数的引用。如果您使用的是基于类的视图CBV),那么您可以使用其as_view()类方法来返回所需的可调用对象。

  • 可选的name参数,我们可以在视图或模板中引用这个path()对象(例如,就像Question模型在其get_absolute_url()方法中所做的那样)。

强烈建议为所有的path()对象命名。

现在,让我们更新我们的根 URLConf 以包括qanda的 URLConf:

from django.contrib import admin
from django.urls import path, include

import qanda.urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include(qanda.urls, namespace='qanda')),
]

这意味着对answerly.example.com/ask的请求将路由到我们的AskQuestionView

本节的快速回顾

在本节中,我们执行了以下操作:

  • 创建了我们的第一个表单,QuestionForm

  • 创建了使用QuestionForm创建QuestionAskQuestionView

  • 创建了一个模板来渲染AskQuestionViewQuestionForm

  • 安装和配置了为我们的模板提供过滤器的第三方库

现在,让我们允许我们的用户使用QuestionDetailView类查看问题。

创建 QuestionDetailView

QuestionDetailView必须提供相当多的功能。它必须能够执行以下操作:

  • 显示问题

  • 显示所有答案

  • 让用户发布额外的答案

  • 让提问者接受答案

  • 让提问者拒绝先前接受的答案

尽管QuestionDetailView不会处理任何表单,但它必须显示许多表单,导致一个复杂的模板。这种复杂性将给我们一个机会来注意如何将模板分割成单独的子模板,以使我们的代码更易读。

创建答案表单

我们需要制作两个表单,以使QuestionDetailView按照前一节的描述工作:

  • AnswerForm:供用户发布他们的答案

  • AnswerAcceptanceForm:供问题的提问者接受或拒绝答案

创建 AnswerForm

AnswerForm将需要引用一个Question模型实例和一个用户,因为这两者都是创建Answer模型实例所必需的。

让我们将我们的AnswerForm添加到django/qanda/forms.py中:

from django import forms
from django.contrib.auth import get_user_model

from qanda.models import Answers

class AnswerForm(forms.ModelForm):
    user = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=get_user_model().objects.all(),
        disabled=True,
    )
    question = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=Question.objects.all(),
        disabled=True,
    )

    class Meta:
        model = Answer
        fields = ['answer', 'user', 'question', ]

AnswerForm类看起来很像QuestionForm类,尽管字段的命名略有不同。它使用了与QuestionForm相同的技术,防止用户篡改与Answer相关联的Question,就像QuestionForm用于防止篡改Question的用户一样。

接下来,我们将创建一个接受Answer的表单。

创建 AnswerAcceptanceForm

如果accepted字段为True,则Answer被接受。我们将使用一个简单的表单来编辑这个字段:

class AnswerAcceptanceForm(forms.ModelForm):
    accepted = forms.BooleanField(
        widget=forms.HiddenInput,
        required=False,
    )

    class Meta:
        model = Answer
        fields = ['accepted', ]

使用BooleanField会有一个小问题。如果我们希望BooleanField接受False值以及True值,我们必须设置required=False。否则,BooleanField在接收到False值时会感到困惑,认为它实际上没有收到值。

我们使用了一个隐藏的输入,因为我们不希望用户勾选复选框然后再点击提交。相反,对于每个答案,我们将生成一个接受表单和一个拒绝表单,用户只需点击一次即可提交。

接下来,让我们编写QuestionDetailView类。

创建 QuestionDetailView

现在我们有了要使用的表单,我们可以在django/qanda/views.py中创建QuestionDetailView

from django.views.generic import DetailView

from qanda.forms import AnswerForm, AnswerAcceptanceForm
from qanda.models import Question

class QuestionDetailView(DetailView):
    model = Question

    ACCEPT_FORM = AnswerAcceptanceForm(initial={'accepted': True})
    REJECT_FORM = AnswerAcceptanceForm(initial={'accepted': False})

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx.update({
            'answer_form': AnswerForm(initial={
                'user': self.request.user.id,
                'question': self.object.id,
            })
        })
        if self.object.can_accept_answers(self.request.user):
            ctx.update({
                'accept_form': self.ACCEPT_FORM,
                'reject_form': self.REJECT_FORM,
            })
        return ctx

QuestionDetailView让 Django 的DetailView完成大部分工作。DetailViewQuestion的默认管理器(Question.objects)中获取一个QuestionQuerySet。然后,DetailView使用QuerySet根据 URL 路径中收到的pk获取一个QuestionDetailView还根据我们的应用程序和模型名称(appname/modelname_detail.html)知道要渲染哪个模板。

我们唯一需要自定义DetailView行为的地方是get_context_data()get_context_data()提供用于呈现模板的上下文。在我们的情况下,我们使用该方法将要呈现的表单添加到上下文中。

接下来,让我们为QuestionDetailView创建模板。

创建 question_detail.html

我们的QuestionDetailView模板将与我们以前的模板略有不同。

以下是我们将放入django/qanda/templates/qanda/question_detail.html中的内容:

{% extends "base.html" %}

{% block title %}{{ question.title }} - {{ block.super }}{% endblock %}

{% block body %}
  {% include "qanda/common/display_question.html" %}
  {% include "qanda/common/list_answers.html" %}
  {% if user.is_authenticated %}
    {% include "qanda/common/question_post_answer.html" %}
  {% else %}
    <div >Login to post answers.</div >
  {% endif %}
{% endblock %}

前面的模板似乎并没有做任何事情。相反,我们使用{% include %}标签将其他模板包含在此模板中,以使我们的代码组织更简单。{% include %}将当前上下文传递给新模板,呈现它,并将其插入到指定位置。

让我们依次查看这些子模板,从dispaly_question.html开始。

创建 display_question.html 通用模板

我们已经将显示问题的 HTML 放入了自己的子模板中。然后其他模板可以包含此模板,以呈现问题。

让我们在django/qanda/templates/qanda/common/display_question.html中创建它:

{% load markdownify %}
<div class="question" >
  <div class="meta col-sm-12" >
    <h1 >{{ question.title }}</h1 >
    Asked by {{ question.user }} on {{ question.created }}
  </div >
  <div class="body col-sm-12" >
    {{ question.question|markdownify }}
  </div >
</div >

HTML 本身非常简单,在这里没有新标签。我们重用了之前配置的markdownify标签和库。

接下来,让我们看一下答案列表模板。

创建 list_answers.html

答案列表模板必须列出问题的所有答案,并渲染答案是否被接受。如果用户可以接受(或拒绝)答案,那么这些表单也会被呈现。

让我们在django/qanda/templates/qanda/view_questions/question_answers.html中创建模板:

{% load markdownify %}
<h3 >Answers</h3 >
<ul class="list-unstyled answers" >
  {% for answer in question.answer_set.all %}
    <li class="answer row" >
      <div class="col-sm-3 col-md-2 text-center" >
        {% if answer.accepted %}
          <span class="badge badge-pill badge-success" >Accepted</span >
        {% endif %}
        {% if answer.accepted and reject_form %}
          <form method="post"
                action="{% url "qanda:update_answer_acceptance" pk=answer.id %}" >
            {% csrf_token %}
            {{ reject_form }}
            <button type="submit" class="btn btn-link" >
              <i class="fa fa-times" aria-hidden="true" ></i>
              Reject
            </button >
          </form >
        {% elif accept_form %}
          <form method="post"
                action="{% url "qanda:update_answer_acceptance" pk=answer.id %}" >
            {% csrf_token %}
            {{ accept_form }}
            <button type="submit" class="btn btn-link" title="Accept answer" >
              <i class="fa fa-check-circle" aria-hidden="true"></i >
              Accept
            </button >
          </form >
        {% endif %}
      </div >
      <div class="col-sm-9 col-md-10" >
        <div class="body" >{{ answer.answer|markdownify }}</div >
        <div class="meta font-weight-light" >
          Answered by {{ answer.user }} on {{ answer.created }}
        </div >
      </div >
    </li >
  {% empty %}
    <li class="answer" >No answers yet!</li >
  {% endfor %}
</ul >

关于这个模板有两件事需要注意:

  • 模板中有一个罕见的逻辑,{% if answer.accepted and reject_form %}。通常,模板应该是简单的,避免了解业务逻辑。然而,避免这种情况会创建一个更复杂的视图。这是我们必须始终根据具体情况评估的权衡。

  • {% empty %}标签与我们的{% for answer in question.answer_set.all %}循环有关。{% empty %}在列表为空的情况下使用,就像 Python 的for ... else语法一样。

接下来,让我们看一下发布答案模板。

创建 post_answer.html 模板

在接下来要创建的模板中,用户可以发布和预览他们的答案。

让我们在django/qanda/templates/qanda/common/post_answer.html中创建我们的下一个模板:

{% load crispy_forms_tags %}

<div class="col-sm-12" >
  <h3 >Post your answer</h3 >
  <form method="post"
        action="{% url "qanda:answer_question" pk=question.id %}" >
    {{ answer_form | crispy }}
    {% csrf_token %}
    <button class="btn btn-primary" type="submit" name="action"
            value="PREVIEW" >Preview
    </button >
    <button class="btn btn-primary" type="submit" name="action"
            value="SAVE" >Answer
    </button >
  </form >
</div >

这个模板非常简单,使用crispy过滤器对answer_form进行渲染。

现在我们所有的子模板都完成了,让我们创建一个path来将请求路由到QuestionDetailView

将请求路由到 QuestionDetail 视图

为了能够将请求路由到我们的QuestionDetailView,我们需要将其添加到django/qanda/urls.py中的 URLConf:

    path('q/<int:pk>', views.QuestionDetailView.as_view(),
         name='question_detail'),

在上述代码中,我们看到path使用了一个名为pk的参数,它必须是一个整数。这将传递给QuestionDetailView并在kwargs字典中可用。DetailView将依赖于此参数的存在来知道要检索哪个Question

接下来,我们将创建一些我们在模板中引用的与表单相关的视图。让我们从CreateAnswerView类开始。

创建 CreateAnswerView

CreateAnswerView类将用于为Question模型实例创建和预览Answer模型实例。

让我们在django/qanda/views.py中创建它:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView

from qanda.forms import AnswerForm

class CreateAnswerView(LoginRequiredMixin, CreateView):
    form_class = AnswerForm
    template_name = 'qanda/create_answer.html'

    def get_initial(self):
        return {
            'question': self.get_question().id,
            'user': self.request.user.id,
        }

    def get_context_data(self, **kwargs):
        return super().get_context_data(question=self.get_question(),
                                        **kwargs)

    def get_success_url(self):
        return self.object.question.get_absolute_url()

    def form_valid(self, form):
        action = self.request.POST.get('action')
        if action == 'SAVE':
            # save and redirect as usual.
            return super().form_valid(form)
        elif action == 'PREVIEW':
            ctx = self.get_context_data(preview=form.cleaned_data['answer'])
            return self.render_to_response(context=ctx)
        return HttpResponseBadRequest()

    def get_question(self):
        return Question.objects.get(pk=self.kwargs['pk'])

CreateAnswerView类遵循与AskQuestionView类类似的模式:

  • 这是一个CreateView

  • 它受LoginRequiredMixin保护

  • 它使用get_initial()为其表单提供初始参数,以便恶意用户无法篡改与答案相关的问题或用户

  • 它使用form_valid()来执行预览或保存操作

主要的区别是我们需要在 CreateAnswerView 中添加一个 get_question() 方法来检索我们要回答的问题。kwargs['pk'] 将由我们将创建的 path 填充(就像我们为 QuestionDetailView 做的那样)。

接下来,让我们创建模板。

创建 create_answer.html

这个模板将能够利用我们已经创建的常见模板元素,使渲染问题和答案表单更容易。

让我们在 django/qanda/templates/qanda/create_answer.html 中创建它:

{% extends "base.html" %}
{% load markdownify %}

{% block body %}
  {% include 'qanda/common/display_question.html' %}
  {% if preview %}
    <div class="card question-preview" >
      <div class="card-header" >
        Answer Preview
      </div >
      <div class="card-body" >
        {{ preview|markdownify }}
      </div >
    </div >
  {% endif %}
  {% include 'qanda/common/post_answer.html' with answer_form=form %}
{% endblock %}

前面的模板介绍了 {% include %} 的新用法。当我们使用 with 参数时,我们可以传递一系列新名称,这些值应该在子模板的上下文中具有。在我们的情况下,我们只会将 answer_form 添加到 post_answer.html 的上下文中。其余的上下文仍然被传递给 {% include %}。如果我们在 {% include %} 的最后一个参数中添加 only,我们可以阻止其余的上下文被传递。

将请求路由到 CreateAnswerView

最后一步是通过在 qanda/urls.pyurlpatterns 列表中添加一个新的 path 来将 CreateAnswerView 连接到 qanda URLConf 中:

   path('q/<int:pk>/answer', views.CreateAnswerView.as_view(),
         name='answer_question'),

接下来,我们将创建一个视图来处理 AnswerAcceptanceForm

创建 UpdateAnswerAcceptanceView

我们在 list_answers.html 模板中使用的 accept_formreject_form 变量需要一个视图来处理它们的表单提交。让我们将其添加到 django/qanda/views.py 中:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import UpdateView

from qanda.forms import AnswerAcceptanceForm
from qanda.models import Answer

class UpdateAnswerAcceptance(LoginRequiredMixin, UpdateView):
    form_class = AnswerAcceptanceForm
    queryset = Answer.objects.all()

    def get_success_url(self):
        return self.object.question.get_absolute_url()

    def form_invalid(self, form):
        return HttpResponseRedirect(
            redirect_to=self.object.question.get_absolute_url())

UpdateView 的工作方式类似于 DetailView(因为它在单个模型上工作)和 CreateView(因为它处理一个表单)。CreateViewUpdateView 共享一个共同的祖先:ModelFormMixinModelFormMixin 为我们提供了我们过去经常使用的钩子:form_valid()get_success_url()form_invalid()

由于这个表单的简单性,我们将通过将用户重定向到问题来响应无效的表单。

接下来,让我们将其添加到我们的 URLConf 中的 django/qanda/urls.py 文件中:

   path('a/<int:pk>/accept', views.UpdateAnswerAcceptance.as_view(),
         name='update_answer_acceptance'),

记得在你的 path() 对象的第一个参数中有一个名为 pk 的参数,这样 UpdateView 就可以检索到正确的 Answer

接下来,让我们创建一个每日问题列表。

创建每日问题页面

为了帮助人们找到问题,我们将创建每天问题的列表。

Django 提供了创建年度、月度、周度和每日归档视图的视图。在我们的情况下,我们将使用 DailyArchiveView,但它们基本上都是一样的。它们从 URL 的路径中获取一个日期,并在该期间搜索所有相关内容。

让我们使用 Django 的 DailyArchiveView 来构建一个每日问题列表。

创建 DailyQuestionList 视图

让我们将我们的 DailyQuestionList 视图添加到 django/qanda/views.py 中:

from django.views.generic import DayArchiveView

from qanda.models import Question

class DailyQuestionList(DayArchiveView):
    queryset = Question.objects.all()
    date_field = 'created'
    month_format = '%m'
    allow_empty = True

DailyQuestionList 不需要覆盖 DayArchiveView 的任何方法,只需让 Django 做这项工作。让我们看看它是如何做到的。

DayArchiveView 期望在 URL 的路径中获取一个日期、月份和年份。我们可以使用 day_formatmonth_formatyear_format 来指定这些的格式。在我们的情况下,我们将期望的格式更改为 '%m',这样月份就会被解析为一个数字,而不是默认的 '%b',这是月份的简称。这些格式与 Python 的标准 datetime.datetime.strftime 相同。一旦 DayArchiveView 有了日期,它就会使用该日期来过滤提供的 queryset,使用在 date_field 属性中命名的字段。queryset 按日期排序。如果 allow_emptyTrue,那么结果将被渲染,否则将抛出 404 异常,对于没有要列出的项目的日期。为了渲染模板,对象列表被传递到模板中,就像 ListView 一样。默认模板假定遵循 appname/modelname_archive_day.html 的格式。

接下来,让我们为这个视图创建模板。

创建每日问题列表模板

让我们将我们的模板添加到 django/qanda/templates/qanda/question_archive_day.html 中:

{% extends "base.html" %}

{% block title %} Questions on {{ day }} {% endblock %}

{% block body %}
  <div class="col-sm-12" >
    <h1 >Highest Voted Questions of {{ day }}</h1 >
    <ul >
      {% for question in object_list %}
        <li >
          {{ question.votes }}
          <a href="{{ question.get_absolute_url }}" >
            {{ question }}
          </a >
          by
            {{ question.user }}
          on {{ question.created }}
        </li >
      {% empty %}
        <li>Hmm... Everyone thinks they know everything today.</li>
      {% endfor %}
    </ul >
    <div>
      {% if previous_day %}
        <a href="{% url "qanda:daily_questions" year=previous_day.year month=previous_day.month day=previous_day.day %}" >
           << Previous Day
        </a >
      {% endif %}
      {% if next_day %}
        <a href="{% url "qanda:daily_questions" year=next_day.year month=next_day.month day=next_day.day %}" >
          Next Day >>
        </a >
      {% endif %}
    </div >
  </div >
{% endblock %}

问题列表就像人们所期望的那样,即一个带有 {% for %} 循环创建 <li> 标签和链接的 <ul> 标签。

DailyArchiveView(以及所有日期存档视图)的一个便利之处是它们提供其模板的上下文,包括下一个和上一个日期。这些日期让我们在日期之间创建一种分页。

将请求路由到 DailyQuestionLists

最后,我们将创建一个path到我们的DailyQuestionList视图,以便我们可以将请求路由到它:

    path('daily/<int:year>/<int:month>/<int:day>/',
         views.DailyQuestionList.as_view(),
         name='daily_questions'),

接下来,让我们创建一个视图来代表今天的问题。

获取今天的问题列表

拥有每日存档是很好的,但我们希望提供一种方便的方式来访问今天的存档。我们将使用RedirectView来始终将用户重定向到今天日期的DailyQuestionList

让我们将其添加到django/qanda/views.py中:

class TodaysQuestionList(RedirectView):
    def get_redirect_url(self, *args, **kwargs):
        today = timezone.now()
        return reverse(
            'questions:daily_questions',
            kwargs={
                'day': today.day,
                'month': today.month,
                'year': today.year,
            }
        )

RedirectView是一个简单的视图,返回 301 或 302 重定向响应。我们使用 Django 的django.util.timezone根据 Django 的配置获取今天的日期。默认情况下,Django 使用协调世界时UTC)进行配置。由于时区的复杂性,通常最简单的方法是在 UTC 中跟踪所有内容,然后在客户端上调整显示。

我们现在已经为我们的初始qanda应用程序创建了所有的视图,让用户提问和回答问题。提问者还可以接受问题的答案。

接下来,让我们让用户实际上可以使用user应用程序登录、注销和注册。

创建用户应用程序

正如我们之前提到的,Django 应用程序应该有一个明确的范围。为此,我们将创建一个单独的 Django 应用程序来管理用户,我们将其称为user。我们不应该将我们的用户管理代码放在qanda或者user应用程序中的Question模型。

让我们使用manage.py创建应用:

$ python manage.py startapp user

然后,将其添加到django/config/settings.pyINSTALLED_APPS列表中:

INSTALLED_APPS = [
    'user',
    'qanda',

    'markdownify',
    'crispy_forms',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

特别重要的是要将user应用程序放在admin应用程序之前,因为它们都将定义登录模板。先到达的应用程序将首先解析其登录模板。我们不希望我们的用户被重定向到管理员应用程序。

接下来,让我们在django/user/urls.py中为我们的user应用程序创建一个 URLConf:

from django.urls import path

import user.views

app_name = 'user'
urlpatterns = [
]

现在,我们将在django/config/urls.py中的主 URLConf 中包含user应用程序的 URLConf:

from django.contrib import admin
from django.urls import path, include

import qanda.urls
import user.urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('user/', include(user.urls, namespace='user')),
    path('', include(qanda.urls, namespace='questions')),
]

现在我们已经配置了我们的应用程序,我们可以添加我们的登录和注销视图。

使用 Django 的 LoginView 和 LogoutView

为了提供登录和注销功能,我们将使用django.contrib.auth应用提供的视图。让我们更新django/users/urls.py来引用它们:

from django.urls import path

import user.views

app_name = 'user'
urlpatterns = [
    path('login', LoginView.as_view(), name='login'),
    path('logout', LogoutView.as_view(), name='logout'),
]

这些视图负责登录和注销用户。然而,登录视图需要一个模板来渲染得漂亮。LoginView期望它在registration/login.html名称下。

我们将模板放在django/user/templates/registration/login.html中:

{% extends "base.html" %}
{% load crispy_forms_tags %}

{% block title %} Login - {{ block.super }} {% endblock %}

{% block body %}
  <h1>Login</h1>
  <form method="post" class="col-sm-6">
    {% csrf_token %}
    {{ form|crispy }}
    <button type="submit" class="btn btn-primary">Login</button>
  </form>
{% endblock %}

LogoutView不需要一个模板。

现在,我们需要通知我们 Django 项目的settings.py关于登录视图的位置以及用户登录和注销时应执行的功能。让我们在django/config/settings.py中添加一些设置:

LOGIN_URL = 'user:login'
LOGIN_REDIRECT_URL = 'questions:index'
LOGOUT_REDIRECT_URL = 'questions:index'

这样,LoginRequiredMixin就可以知道我们需要将未经身份验证的用户重定向到哪个视图。我们还通知了django.contrib.authLoginViewLogoutView在用户登录和注销时分别将用户重定向到哪里。

接下来,让我们为用户提供一种注册网站的方式。

创建 RegisterView

Django 不提供用户注册视图,但如果我们使用django.conrib.auth.models.User作为用户模型,它确实提供了一个UserCreationForm。由于我们使用django.conrib.auth.models.User,我们可以为我们的注册视图使用一个简单的CreateView

from django.contrib.auth.forms import UserCreationForm
from django.views.generic.edit import CreateView

class RegisterView(CreateView):
    template_name = 'user/register.html'
    form_class = UserCreationForm

现在,我们只需要在django/user/templates/register.html中创建一个模板:

{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block body %}
  <div class="col-sm-12">
    <h1 >Register for MyQA</h1 >
    <form method="post" >
      {% csrf_token %}
      {{ form | crispy }}
      <button type="submit" class="btn btn-primary" >
        Register
      </button >
    </form >
  </div >
{% endblock %}

同样,我们的模板遵循了一个熟悉的模式,类似于我们在过去的视图中看到的。我们使用我们的基本模板、块和 Django Crispy Form 来快速简单地创建我们的页面。

最后,我们可以在user URLConf 的urlpatterns列表中添加一个path到该视图:

path('register', user.views.RegisterView.as_view(), name='register'),

更新 base.html 导航

现在我们已经创建了所有的视图,我们可以更新我们基础模板的<nav>来列出所有我们的 URL:

{% load static %}
<!DOCTYPE html>
<html lang="en" >
<head >
{# skipping unchanged head contents #}
</head >
<body >
<nav class="navbar navbar-expand-lg  bg-light" >
  <div class="container" >
    <a class="navbar-brand" href="/" >Answerly</a >
    <ul class="navbar-nav" >
      <li class="nav-item" >
        <a class="nav-link" href="{% url "qanda:ask" %}" >Ask</a >
      </li >
      <li class="nav-item" >
        <a
            class="nav-link"
            href="{% url "qanda:index" %}" >
          Today's  Questions
        </a >
      </li >
      {% if user.is_authenticated %}
        <li class="nav-item" >
          <a class="nav-link" href="{% url "user:logout" %}" >Logout</a >
        </li >
      {% else %}
        <li class="nav-item" >
          <a class="nav-link" href="{% url "user:login" %}" >Login</a >
        </li >
        <li class="nav-item" >
          <a class="nav-link" href="{% url "user:register" %}" >Register</a >
        </li >
      {% endif %}
    </ul >
  </div >
</nav >
<div class="container" >
  {% block body %}{% endblock %}
</div >
</body >
</html >

太好了!现在我们的用户可以随时访问我们网站上最重要的页面。

运行开发服务器

最后,我们可以使用以下命令访问我们的开发服务器:

$ cd django
$ python manage.py runserver

现在我们可以在浏览器中打开网站 localhost:8000/

总结

在本章中,我们开始了 Answerly 项目。Answerly 由两个应用程序(userqanda)组成,通过 PyPI 安装了两个第三方应用程序(Markdownify 和 Django Crispy Forms),以及一些 Django 内置应用程序(django.contrib.auth被直接使用)。

已登录用户现在可以提问,回答问题,并接受答案。我们还可以看到每天投票最高的问题。

接下来,我们将通过使用 ElasticSearch 添加搜索功能,帮助用户更轻松地发现问题。

第七章:使用 Elasticsearch 搜索问题

现在用户可以提问和回答问题,我们将为 Answerly 添加搜索功能,以帮助用户找到问题。我们的搜索将由 Elasticsearch 提供支持。Elasticsearch 是一个由 Apache Lucene 提供支持的流行的开源搜索引擎。

在本章中,我们将执行以下操作:

  • 创建一个 Elasticsearch 服务来抽象我们的代码

  • 批量加载现有的Question模型实例到 Elasticsearch

  • 构建由 Elasticsearch 提供支持的搜索视图

  • 自动将新模型保存到 Elasticsearch

让我们首先设置我们的项目以使用 Elasticsearch。

从 Elasticsearch 开始

Elasticsearch 由 Elastic 维护,尽管服务器是开源的。Elastic 提供专有插件,以使在生产中运行更容易。您可以自己运行 Elasticsearch,也可以使用 Amazon、Google 或 Elastic 等 SaaS 提供商。在开发中,我们将使用 Elastic 提供的 Docker 镜像运行 Elasticsearch。

Elasticsearch 由零个或多个索引组成。每个索引包含文档。文档是搜索的对象。文档由字段组成。字段由 Apache Lucene 索引。每个索引还分成一个或多个分片,通过在集群中的节点之间分发来加快索引和搜索速度。

我们可以使用其 RESTful API 与 Elasticsearch 进行交互。大多数请求和响应默认都是 JSON 格式。

首先,让我们通过在 Docker 中运行 Elasticsearch 服务器来开始。

使用 docker 启动 Elasticsearch 服务器

运行 Elasticsearch 服务器的最简单方法是使用 Elastic 提供的 Docker 镜像。

要获取并启动 Elasticsearch docker 镜像,请运行以下命令:

$ docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.0.0

以下命令执行四个操作,如下所示:

  • 它从 Elastic 的服务器下载 Elasticsearch 6.0 docker 镜像

  • 它使用 Elasticsearch 6.0 docker 镜像作为单节点集群运行容器

  • 它将 docker 命令从运行的容器中分离(这样我们就可以在我们的 shell 中运行更多命令)

  • 它在主机计算机上打开端口(-p92009300,并将它们重定向到容器

要确认我们的服务器正在运行,我们可以向 Elasticsearch 服务器发出以下请求:

$ curl http://localhost:9200/?pretty
{
  "name" : "xgf60cc",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "HZAnjZefSjqDOxbMU99KOw",
  "version" : {
    "number" : "6.0.0",
    "build_hash" : "8f0685b",
    "build_date" : "2017-11-10T18:41:22.859Z",
    "build_snapshot" : false,
    "lucene_version" : "7.0.1",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}

与 Elasticsearch 交互时,始终添加pretty GET参数,以便 Elasticsearch 打印 JSON。但是,在代码中不要使用此参数。

现在我们有了 Elasticsearch 服务器,让我们配置 Django 以了解我们的服务器。

配置 Answerly 以使用 Elasticsearch

接下来,我们将更新我们的settings.pyrequirements.txt文件,以便与 Elasticsearch 一起使用。

让我们更新django/config/settings.py

ES_INDEX = 'answerly'
ES_HOST = 'localhost'
ES_PORT = '9200'

这些是我们的应用程序将使用的自定义设置。Django 没有内置对 Elasticsearch 的支持。相反,我们将在我们自己的代码中引用这些设置。

让我们将 Elasticsearch 库添加到我们的requirements.txt文件中:

elasticsearch==6.0.0

这是由 Elastic 发布的官方 Elasticsearch Python 库。该库提供了一个低级接口,看起来很像我们可以用 cURL 与之一起使用的 RESTful API。这意味着我们可以轻松地在命令行上使用 cURL 构建查询,然后将 JSON 转换为 Pythondict

Elastic 还提供了一个更高级、更 Pythonic 的 API,称为elasticsearch-dsl。它包括一个伪 ORM,用于编写更 Pythonic 的持久层。如果您的项目包含大量 Elasticsearch 代码,这可能是一个不错的选择。但是,低级 API 与 RESTful API 密切对应,这使得重用代码并从 Elasticsearch 社区获得帮助更容易。

接下来,让我们在我们的 Elasticsearch 服务器中创建 Answerly 索引。

创建 Answerly 索引

让我们通过向服务器发送PUT请求来在 Elasticsearch 中创建索引:

$ curl -XPUT "localhost:9200/answerly?pretty"

太好了!现在,我们可以将现有的Question模型实例加载到我们的 Elasticsearch 索引中。

将现有的问题加载到 Elasticsearch 中

添加搜索功能意味着我们需要将现有的Question模型实例加载到 Elasticsearch 中。解决这样的问题最简单的方法是添加一个manage.py命令。自定义的manage.py命令将普通 Python 脚本的简单性与 Django API 的强大功能结合起来。

在添加manage.py命令之前,我们需要编写我们的特定于 Elasticsearch 的代码。为了将 Elasticsearch 代码与 Django 代码分离,我们将在qanda应用程序中添加一个elasticsearch服务。

创建 Elasticsearch 服务

本章中我们将编写的大部分代码都是特定于 Elasticsearch 的。我们不希望将该代码放在我们的视图(或manage.py命令)中,因为这将在两个不相关的组件之间引入耦合。相反,我们将把 Elasticsearch 代码隔离到qanda中的自己的模块中,然后让我们的视图和manage.py命令调用我们的服务模块。

我们将创建的第一个函数将批量加载Question模型实例到 Elasticsearch 中。

让我们为我们的 Elastic 服务代码创建一个单独的文件。我们将把我们的批量插入代码放入django/qanda/service/elasticsearch.py中:

import logging

from django.conf import settings
from elasticsearch import Elasticsearch, TransportError
from elasticsearch.helpers import streaming_bulk

FAILED_TO_LOAD_ERROR = 'Failed to load {}: {!r}'

logger = logging.getLogger(__name__)

def get_client():
    return Elasticsearch(hosts=[
        {'host': settings.ES_HOST, 'port': settings.ES_PORT,}
    ])

def bulk_load(questions):
    all_ok = True
    es_questions = (q.as_elasticsearch_dict() for q in questions)
    for ok, result in streaming_bulk(
            get_client(),
            es_questions,
            index=settings.ES_INDEX,
            raise_on_error=False,
    ):
        if not ok:
            all_ok = False
            action, result = result.popitem()
            logger.error(FAILED_TO_LOAD_ERROR.format(result['_id'], result))
    return all_ok

我们在新服务中创建了两个函数,get_client()bulk_load()

get_client()函数将返回一个从settings.py中配置的Elasticcearch客户端。

bulk_load()函数接受一个Question模型实例的可迭代集合,并使用streaming_bulk()助手将它们加载到 Elasticsearch 中。由于bulk_load()期望一个可迭代的集合,这意味着我们的manage.py命令将能够发送一个QuerySet对象。请记住,即使我们使用了生成器表达式(它是惰性的),我们的questions参数也会在我们尝试迭代它时执行完整的查询。只有as_elasticsearch_dict()方法的执行是惰性的。我们将在完成查看bulk_load()函数后编写并讨论新的as_elasticsearch_dict()方法。

接下来,bulk_load()函数使用streaming_bulk()函数。streaming_bulk()函数接受四个参数并返回一个用于报告加载进度的迭代器。四个参数如下:

  • 一个Elasticsearch客户端

  • 我们的Question生成器(迭代器)

  • 索引名称

  • 一个标志,告诉函数在出现错误时不要引发异常(这将导致ok变量在出现错误时为False

我们的for循环的主体将在加载问题时出现错误时记录日志。

接下来,让我们给Question一个方法,可以将其转换为 Elasticsearch 可以正确处理的dict

让我们更新Question模型:

from django.db import models

class Question(models.Model):
    # fields and methods unchanged 

    def as_elasticsearch_dict(self):
        return {
            '_id': self.id,
            '_type': 'doc',
            'text': '{}\n{}'.format(self.title, self.question),
            'question_body': self.question,
            'title': self.title,
            'id': self.id,
            'created': self.created,
        }

as_elasticsearch_dict()方法将Question模型实例转换为适合加载到 Elasticsearch 中的字典。以下是我们特别添加到 Elasticsearch 字典中的三个字段,这些字段不在我们的模型中:

  • _id:这是 Elasticsearch 文档的 ID。这不一定要与模型 ID 相同。但是,如果我们想要能够更新代表“问题”的 Elasticsearch 文档,那么我们需要存储文档的_id或能够计算它。为简单起见,我们只使用相同的 ID。

  • _type:这是文档的映射类型。截至 Elasticsearch 6,Elasticsearch 索引只能存储一个映射类型。因此,索引中的所有文档应该具有相同的_type值。映射类型类似于数据库模式,告诉 Elasticsearch 如何索引和跟踪文档及其字段。Elasticsearch 的一个便利功能是,它不要求我们提前定义类型。Elasticsearch 会根据我们加载的数据动态构建文档的类型。

  • text:这是我们将在文档中创建的一个字段。对于搜索来说,将文档的标题和正文放在一个可索引的字段中是很方便的。

字典中的其余字段与模型的字段相同。

作为模型方法的as_elasticsearch_dict()的存在可能会有问题。elasticsearch服务不应该知道如何将Question转换为 Elasticsearch 字典吗?像许多设计问题一样,答案取决于各种因素。影响我将此方法添加到模型中的一个因素是 Django 的fat models哲学。通常,Django 鼓励在模型方法上编写操作。此外,此字典的属性与模型的字段耦合。将这两个字段列表保持紧密联系使未来的开发人员更容易保持两个列表同步。然而,在某些项目和环境中,将这种函数放在服务模块中可能是正确的选择。作为 Django 开发人员,我们的工作是评估权衡并为特定项目做出最佳决策。

现在我们的elasticsearch服务知道如何批量添加Questions,让我们用manage.py命令暴露这个功能。

创建一个 manage.py 命令

我们已经使用manage.py命令来启动项目和应用程序,以及创建和运行迁移。现在,我们将创建一个自定义命令,将我们项目中的所有问题加载到 Elasticsearch 服务器中。这将是对 Django 管理命令的简单介绍。我们将在第十二章中更详细地讨论这个主题,构建 API

Django 管理命令必须位于应用程序的manage/commands子目录中。一个应用程序可以有多个命令。每个命令的名称与其文件名相同。文件内部应该有一个继承django.core.management.BaseCommandCommand类,它应该执行的代码应该在handle()方法中。

让我们在django/qanda/management/commands/load_questions_into_elastic_search.py中创建我们的命令:

from django.core.management import BaseCommand

from qanda.service import elasticsearch
from qanda.models import Question

class Command(BaseCommand):
    help = 'Load all questions into Elasticsearch'

    def handle(self, *args, **options):
        queryset = Question.objects.all()
        all_loaded = elasticsearch.bulk_load(queryset)
        if all_loaded:
            self.stdout.write(self.style.SUCCESS(
                'Successfully loaded all questions into Elasticsearch.'))
        else:
            self.stdout.write(
                self.style.WARNING('Some questions not loaded '
                                   'successfully. See logged errors'))

在设计命令时,我们应该将它们视为视图,即Fat models, thin commands。这可能会更复杂一些,因为命令行输出没有单独的模板层,但我们的输出也不应该很复杂。

在我们的情况下,handle()方法获取所有QuestionsQuerySet,然后将其传递给elasticsearch.bulkload。然后我们使用Command的辅助方法打印出是否成功或不成功。这些辅助方法优于直接使用print(),因为它们使编写测试更容易。我们将在下一章第八章中更详细地讨论这个主题,测试 Answerly

让我们运行以下命令:

$ cd django
$ python manage.py load_questions_into_elastic_search
Successfully loaded all questions into Elasticsearch.

当所有问题加载完毕后,让我们确认它们是否在我们的 Elasticsearch 服务器中。我们可以使用curl访问 Elasticsearch 服务器,以确认我们的问题已经加载:

$ curl http://localhost:9200/answerly/_search?pretty

假设您的 ElasticSearch 服务器在本地主机的端口 9200 上运行,上述命令将返回answerly索引中的所有数据。我们可以查看结果来确认我们的数据已成功加载。

现在我们在 Elasticsearch 中有一些问题,让我们添加一个搜索视图。

创建一个搜索视图

在本节中,我们将创建一个视图,让用户搜索我们的Question并显示匹配的结果。为了实现这个结果,我们将做以下事情:

  • 在我们的elasticsearch服务中添加一个search_for_question()函数

  • 创建一个搜索视图

  • 创建一个模板来显示搜索结果

  • 更新基本模板以使搜索在任何地方都可用

让我们从为我们的elasticsearch服务添加搜索开始。

创建一个搜索功能

查询我们的 Elasticsearch 服务器以获取与用户查询匹配的问题列表的责任属于我们的elasticsearch服务。

让我们添加一个函数,将搜索查询发送到django/qanda/service/elasticsearch.py并解析结果:

def search_for_questions(query):
    client = get_client()
    result = client.search(index=settings.ES_INDEX, body={
      'query': {
          'match': {
              'text': query,
          },
      },
    })
    return (h['_source'] for h in result['hits']['hits'])

连接客户端后,我们将发送我们的查询并解析结果。

使用客户端的search()方法,我们将查询作为 Python dict发送到 Elasticsearch Query DSL(领域特定语言)中。Elasticsearch Query DSL 提供了一个用于使用一系列嵌套对象查询 Elasticsearch 的语言。通过 HTTP 发送时,查询变成一系列嵌套的 JSON 对象。在 Python 中,我们使用dict

在我们的情况下,我们在 Answerly 索引的文档的text字段上使用了match查询。match查询是一个模糊查询,检查每个文档的text字段是否匹配。查询 DSL 还支持许多配置选项,让您构建更复杂的查询。在我们的情况下,我们将接受默认的模糊配置。

接下来,search_for_questions遍历结果。Elasticsearch 返回了大量描述结果数量、匹配质量和结果文档的元数据。在我们的情况下,我们将返回匹配文档的迭代器(存储在_source中)。

现在我们可以从 Elasticsearch 获取结果,我们可以编写我们的SearchView

创建 SearchView

我们的SearchView将使用GET参数q并使用我们的服务模块的search_for_questions()函数进行搜索。

我们将使用TemplateView构建我们的SearchViewTemplateView在响应GET请求时呈现模板。让我们将SearchView添加到django/qanda/views.py中:

from django.views.generic import TemplateView

from qanda.service.elasticsearch import search_for_questions

class SearchView(TemplateView):
    template_name = 'qanda/search.html'

    def get_context_data(self, **kwargs):
        query = self.request.GET.get('q', None)
        ctx = super().get_context_data(query=query, **kwargs)
        if query:
            results = search_for_questions(query)
            ctx['hits'] = results
        return ctx

接下来,我们将在django/qanda/urls.py的 URLConf 中添加一个path()对象路由到我们的SearchView

from django.urls.conf import path, include

from qanda import views

app_name = 'qanda'

urlpatterns = [
    # skipping previous code
    path('q/search', views.SearchView.as_view(),
         name='question_search'),
]

现在我们有了我们的视图,让我们构建我们的search.html模板。

创建搜索模板

我们将把搜索模板放在django/qanda/templates/qanda/search.html中,如下所示:

{% extends "base.html" %}

{% load markdownify %}

{% block body %}
  <h2 >Search</h2 >
  <form method="get" class="form-inline" >
    <input class="form-control mr-2"
           placeholder="Search"
           type="search"
           name="q" value="{{ query }}" >
    <button type="submit" class="btn btn-primary" >Search</button >
  </form >
  {% if query %}
    <h3>Results from search query '{{ query }}'</h3 >
    <ul class="list-unstyled search-results" >
      {% for hit in hits %}
        <li >
          <a href="{% url "qanda:question_detail" pk=hit.id %}" >
            {{ hit.title }}
          </a >
          <div >
            {{ hit.question_body|markdownify|truncatewords_html:20 }}
          </div >
        </li >
      {% empty %}
        <li >No results.</li >
      {% endfor %}
    </ul >
  {% endif %}
{% endblock %}

在模板的正文中,我们有一个显示查询的搜索表单。如果有query,那么我们也将显示其结果(如果有的话)。

我们之前在这里使用过许多标签(例如forifurlmarkdownify)。我们将添加一个新的过滤器truncate_words_html,它通过管道接收文本和一个数字作为参数。它将把文本截断为提供的单词数(不包括 HTML 标记),并关闭结果片段中的任何打开的 HTML 标记。

这个模板的结果是一个与我们的查询匹配的命中列表,每个问题的文本预览。由于我们在 Elasticsearch 中存储了问题的正文、标题和 ID,我们能够在不查询我们的常规数据库的情况下显示结果。

接下来,让我们更新基础模板,让用户可以从任何页面进行搜索。

更新基础模板

让我们更新基础模板,让用户可以从任何地方进行搜索。为此,我们需要编辑django/templates/base.html

{% load static %}
<!DOCTYPE html>
<html lang="en" >
<head >{# head unchanged #}</head >
<body >
<nav class="navbar navbar-expand-lg  bg-light" >
  <div class="container" >
    <a class="navbar-brand" href="/" >Answerly</a >
    <ul class="navbar-nav" >
      {# previous nav unchanged #}  
      <li class="nav-item" >
        <form class="form-inline"
              action="{% url "qanda:question_search" %}"
              method="get">
          <input class="form-control mr-sm-2" type="search"
                 name="q"
                 placeholder="Search">
          <button class="btn btn-outline-primary my-2 my-sm-0" 
                 type="submit" >
            Search
          </button >
        </form >
      </li >
    </ul >
  </div >
</nav >
{# rest of body unchanged #}
</body >
</html >

现在,我们在每个页面的页眉中有了搜索表单。

完成搜索后,让我们确保每个新问题都会自动添加到 Elasticsearch 中。

在保存时将问题添加到 Elasticsearch 中

每次保存模型时执行操作的最佳方法是覆盖模型从Model继承的save()方法。我们将提供自定义的Question.save()方法,以确保Question在被 Django ORM 保存时立即添加和更新到 ElasticSearch 中。

即使您不控制该模型的源代码,您仍然可以在保存 Django 模型时执行操作。Django 提供了一个信号分发器(docs.djangoproject.com/en/2.0/topics/signals/),让您可以监听您不拥有的模型上的事件。但是,信号会给您的代码引入大量复杂性。除非没有其他选择,否则不建议使用信号。

让我们更新django/qanda/models.py中的Queston模型:

from django.db import models
from qanda.service import elasticsearch
class Question(models.Model):
    # other fields and methods unchanged. 
    def save(self, force_insert=False, force_update=False, using=None,
             update_fields=None):
        super().save(force_insert=force_insert,
                     force_update=force_update,
                     using=using,
                     update_fields=update_fields)
        elasticsearch.upsert(self)

save()方法被CreateViewUpdateViewQuerySet.create()Manager.create()和大多数第三方代码调用以持久化模型。我们确保在原始save()方法返回后调用我们的upsert()方法,因为我们希望我们的模型有一个id属性。

现在,让我们创建我们的 Elasticsearch 服务的upsert方法。

测量代码覆盖率

代码覆盖测量了测试期间执行的代码行。理想情况下,通过跟踪代码覆盖,我们可以确保哪些代码经过了测试,哪些代码没有。由于 Django 项目主要是 Python,我们可以使用 Coverage.py 来测量我们的代码覆盖率。以下是 Django 项目的两个注意事项:

  • Coverage.py 无法测量我们的模板的覆盖范围(它们不是 Python)

  • 未经测试的基于类的视图似乎比它们实际覆盖的要多

查找 Django 应用程序的覆盖范围是一个两步过程:

  1. 使用coverage命令运行我们的测试

  2. 使用coverage reportcoverage html生成覆盖报告

让我们使用coverage运行 Django 的单元test命令,查看未经测试的项目的基线:

$ coverage run --branch --source=qanda,user manage.py test 
Creating test database for alias 'default'...
System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
Destroying test database for alias 'default'...

上述命令告诉coverage运行一个命令(在我们的情况下是manage.py test)来记录测试覆盖率。我们将使用此命令和以下两个选项:

  • --branch:跟踪分支语句的两个部分是否都被覆盖(例如,当if语句评估为TrueFalse时)

  • --source=qanda,user:仅记录qandauser模块(我们编写的代码)的覆盖范围

现在我们已经记录了覆盖率,让我们看一下没有任何测试的应用程序的覆盖率:

$ coverage report 
Name                                 Stmts   Miss Branch BrPart  Cover
----------------------------------------------------------------------
qanda/__init__.py                      0      0      0      0   100%
qanda/admin.py                         1      0      0      0   100%
qanda/apps.py                          3      3      0      0     0%
qanda/forms.py                        19      0      0      0   100%
qanda/management/__init__.py           0      0      0      0   100%
qanda/migrations/0001_initial.py       7      0      0      0   100%
qanda/migrations/__init__.py           0      0      0      0   100%
qanda/models.py                       28      6      0      0    79%
qanda/search_indexes.py                0      0      0      0   100%
qanda/service/__init__.py              0      0      0      0   100%
qanda/service/elasticsearch.py        47     32     14      0    25%
qanda/tests.py                         1      0      0      0   100%
qanda/urls.py                          4      0      0      0   100%
qanda/views.py                        76     35     12      0    47%
user/__init__.py                         0      0      0      0   100%
user/admin.py                            4      0      0      0   100%
user/apps.py                             3      3      0      0     0%
user/migrations/__init__.py              0      0      0      0   100%
user/models.py                           1      0      0      0   100%
user/tests.py                            1      0      0      0   100%
user/urls.py                             5      0      0      0   100%
user/views.py                            5      0      0      0   100%
----------------------------------------------------------------------
TOTAL                                  205     79     26      0    55%

为了了解未经测试的项目为何覆盖率达到 55%,让我们看一下django/qanda/views.py的覆盖情况。让我们使用以下命令生成覆盖的 HTML 报告:

$ cd django
$ coverage html

上述命令将创建一个django/htmlcov目录和 HTML 文件,显示覆盖报告和代码覆盖的可视化显示。让我们打开django/htmlcov/qanda_views_py.html并向下滚动到大约第 72 行:

上述屏幕截图显示DailyQuestionList完全被覆盖,但QuestionDetailView.get_context_data()没有被覆盖。在没有任何测试的情况下,这种差异似乎有违直觉。

让我们回顾一下代码覆盖的工作原理。代码覆盖工具检查在测试期间是否执行了特定行的代码。在上述屏幕截图中,DailyQuestionList类及其成员已经被执行。当测试运行程序启动时,Django 将构建根 URLConf,就像在开发或生产时启动一样。创建根 URLConf 时,它会导入其他引用的 URLConfs(例如qanda.urls)。这些 URLConfs 又会导入它们的视图。视图导入表单,模型和其他模块。

这个导入链意味着模块顶层的任何内容都会显示为覆盖的,无论是否经过测试。DailyQuestionList的类定义被执行。但是,类本身没有被实例化,也没有执行任何方法。这也解释了为什么QuestionDetailView.get_context_data()的主体部分没有被覆盖。QuestionDetailView.get_context_data()的主体部分从未被执行。这是代码覆盖工具在处理声明性代码(例如DailyQuestionList)时的一个限制。

现在我们了解了代码覆盖的一些限制,让我们为qanda.models.Question.save()编写一个单元测试。

向 Elasticsearch 插入数据

如果对象存在,则 upsert 操作将更新对象,如果不存在则插入。Upsert 是updateinsert的合成词。Elasticsearch 支持开箱即用的 upsert 操作,这可以使我们的代码更简单。

让我们将我们的upsert()方法添加到django/qanda/service/elastic_search.py中:

def upsert(question_model):
    client = get_client()
    question_dict = question_model.as_elasticsearch_dict()
    doc_type = question_dict['_type']
    del question_dict['_id']
    del question_dict['_type']
    response = client.update(
        settings.ES_INDEX,
        doc_type,
        id=question_model.id,
        body={
            'doc': question_dict,
            'doc_as_upsert': True,
        }
    )
    return response

我们在上述代码块中定义了我们的get_client()函数。

要执行 upsert,我们使用 Elasticsearch clientupdate()方法。我们将模型作为文档dict提供,在doc键下。为了强制 Elasticsearch 执行 upsert,我们将包含doc_as_upsert键,并赋予True值。update()方法和我们之前使用的批量插入函数之间的一个区别是,update()不会在文档中接受隐式 ID(_id)。但是,我们在update()调用中提供要 upsert 的文档的 ID 作为id参数。我们还从question_model.as_elasticsearch_dict()方法返回的dict中删除_type键和值,并将值(存储在doc_type变量中)作为参数传递给client.update()方法。

我们返回响应,尽管我们的视图不会使用它。

最后,我们可以通过运行开发服务器来测试我们的视图:

$ cd django
$ python manage.py runserver

一旦我们的开发服务器启动,我们可以在localhost:8000/ask提出一个新问题,然后在localhost:8000/q/search进行搜索。

现在,我们已经完成了向 Answerly 添加搜索功能!

摘要

在本章中,我们添加了搜索功能,以便用户可以搜索问题。我们使用 Docker 为开发设置了一个 Elasticsearch 服务器。我们创建了一个manage.py命令,将所有我们的Question加载到 Elasticsearch 中。我们添加了一个搜索视图,用户可以在其中看到他们问题的结果。最后,我们更新了Question.save以保持 Elasticsearch 和 Django 数据库同步。

接下来,我们将深入了解测试 Django 应用程序,以便在未来进行更改时可以有信心。

第八章:测试 Answerly

在上一章中,我们为我们的问题和答案网站 Answerly 添加了搜索功能。然而,随着我们网站功能的增长,我们需要避免破坏现有的功能。为了确保我们的代码保持正常运行,我们将更仔细地测试我们的 Django 项目。

在本章中,我们将做以下事情:

  • 安装 Coverage.py 以测量代码覆盖率

  • 测量我们的 Django 项目的代码覆盖率

  • 为我们的模型编写单元测试

  • 为视图编写单元测试

  • 为视图编写 Django 集成测试

  • 为视图编写 Selenium 集成测试

让我们从安装 Coverage.py 开始。

安装 Coverage.py

Coverage.py是目前最流行的 Python 代码覆盖工具。它非常容易安装,因为可以从 PyPI 获取。让我们将其添加到我们的requirements.txt文件中:

$ echo "coverage==4.4.2" >> requirements.txt

然后我们可以使用 pip 安装 Coverage.py:

$ pip install -r requirements.txt

现在我们已经安装了 Coverage.py,我们可以开始测量我们的代码覆盖率。

为 Question.save()创建一个单元测试

Django 帮助您编写单元测试来测试代码的各个单元。如果我们的代码依赖于外部服务,那么我们可以使用标准的unittest.mock库来模拟该 API,防止对外部系统的请求。

让我们为Question.save()方法编写一个测试,以验证当我们保存一个Question时,它将被插入到 Elasticsearch 中。我们将在django/qanda/tests.py中编写这个测试:

from unittest.mock import patch

from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import TestCase
from elasticsearch import Elasticsearch

from qanda.models import Question

class QuestionSaveTestCase(TestCase):
    """
    Tests Question.save()
    """

    @patch('qanda.service.elasticsearch.Elasticsearch')
    def test_elasticsearch_upsert_on_save(self, ElasticsearchMock):
        user = get_user_model().objects.create_user(
            username='unittest',
            password='unittest',
        )
        question_title = 'Unit test'
        question_body = 'some long text'
        q = Question(
            title=question_title,
            question=question_body,
            user=user,
        )
        q.save()

        self.assertIsNotNone(q.id)
        self.assertTrue(ElasticsearchMock.called)
        mock_client = ElasticsearchMock.return_value
        mock_client.update.assert_called_once_with(
            settings.ES_INDEX,
            id=q.id,
            body={
                'doc': {
                    '_type': 'doc',
                    'text': '{}\n{}'.format(question_title, question_body),
                    'question_body': question_body,
                    'title': question_title,
                    'id': q.id,
                    'created': q.created,
                },
                'doc_as_upsert': True,
            }
        )

在上面的代码示例中,我们创建了一个带有单个测试方法的TestCase。该方法创建一个用户,保存一个新的Question,然后断言模拟行为是否正确。

像大多数TestCase一样,QuestionSaveTestCase既使用了 Django 的测试 API,也使用了 Python 的unittest库中的代码(例如,unittest.mock.patch())。让我们更仔细地看看 Django 的测试 API 如何使测试更容易。

QuestionSaveTestCase扩展了django.test.TestCase而不是unittest.TestCase,因为 Django 的TestCase提供了许多有用的功能,如下所示:

  • 整个测试用例和每个测试都是原子数据库操作

  • Django 在每次测试前后都会清除数据库

  • TestCase提供了方便的assert*()方法,比如self.assertInHTML()(在为视图创建单元测试部分中更多讨论)

  • 一个虚假的 HTTP 客户端来创建集成测试(在为视图创建集成测试部分中更多讨论)

由于 Django 的TestCase扩展了unittest.TestCase,因此当它遇到常规的AssertionError时,它仍然能够理解并正确执行。因此,如果mock_client.update.assert_called_once_with()引发AssertionError异常,Django 的测试运行器知道如何处理它。

让我们用manage.py运行我们的测试:

$ cd django
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.094s

OK
Destroying test database for alias 'default'...

现在我们知道如何测试模型,我们可以继续测试视图。然而,在测试视图时,我们需要创建模型实例。使用模型的默认管理器来创建模型实例会变得太啰嗦。接下来,让我们使用 Factory Boy 更容易地创建测试所需的模型。

使用 Factory Boy 创建测试模型

在我们之前的测试中,我们使用User.models.create_user创建了一个User模型。然而,这要求我们提供用户名和密码,而我们并不真正关心。我们只需要一个用户,而不是特定的用户。对于我们的许多测试来说,QuestionAnswer也是如此。Factory Boy 库将帮助我们在测试中简洁地创建模型。

Factory Boy 对 Django 开发人员特别有用,因为它知道如何基于 Django 的Model类创建模型。

让我们安装 Factory Boy:

$ pip install factory-boy==2.9.2

在这一部分,我们将使用 Factory Boy 创建一个UserFactory类和一个QuestionFactory类。由于Question模型必须在其user字段中有一个用户,QuestionFactory将向我们展示Factory类如何相互引用。

让我们从UserFactory开始。

创建一个 UserFactory

QuestionAnswer都与用户相关联。这意味着我们几乎在所有测试中都需要创建用户。使用模型管理器为每个测试生成所有相关模型非常冗长,并且分散了我们测试的重点。Django 为我们的测试提供了开箱即用的支持。但是,Django 的 fixtures 是单独的 JSON/YAML 文件,需要手动维护,否则它们将变得不同步并引起问题。Factory Boy 将通过让我们使用代码来帮助我们,即UserFactory,可以根据当前用户模型的状态在运行时简洁地创建用户模型实例。

我们的UserFactory将派生自 Factory Boy 的DjangoModelFactory类,该类知道如何处理 Django 模型。我们将使用内部Meta类告诉UserFactory它正在创建哪个模型(请注意,这与FormAPI 类似)。我们还将添加类属性以告诉 Factory Boy 如何设置模型字段的值。最后,我们将重写_create方法,使UserFactory使用管理器的create_user()方法而不是默认的create()方法。

让我们在django/users/factories.py中创建我们的UserFactory

from django.conf import settings

import factory

class UserFactory(factory.DjangoModelFactory):
    username = factory.Sequence(lambda n: 'user %d' % n)
    password = 'unittest'

    class Meta:
        model = settings.AUTH_USER_MODEL

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        manager = cls._get_manager(model_class)
        return manager.create_user(*args, **kwargs)

UserFactoryDjangoModelFactory的子类。DjangoModelFactory将查看我们类的Meta内部类(遵循与Form类相同的模式)。

让我们更仔细地看一下UserFactory的属性:

  • password = 'unittest':这将为每个用户设置相同的密码。

  • username = factory.Sequence(lambda n: 'user %d' % n): Sequence为每次工厂创建模型时的字段设置不同的值。Sequence()接受可调用对象,将其传递给工厂使用的次数,并使用可调用对象的返回值作为新实例的字段值。在我们的情况下,我们的用户将具有用户名,例如user 0user 1

最后,我们重写了_create()方法,因为django.contrib.auth.models.User模型具有异常的管理器。DjangoModelFactory的默认_create方法将使用模型的管理器的create()方法。对于大多数模型来说,这很好,但对于User模型来说效果不佳。要创建用户,我们应该真正使用create_user方法,以便我们可以传递明文密码并对其进行哈希处理以进行存储。这将让我们作为该用户进行身份验证。

让我们在 Django shell 中尝试一下我们的工厂:

$ cd django
$ python manage.py shell
Python 3.6.3 (default, Oct 31 2017, 11:15:24) 
Type 'copyright', 'credits' or 'license' for more information
IPython 6.2.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: from user.factories import UserFactory
In [2]:  user = UserFactory()
In [3]: user.username
Out[3]: 'user 0'
In [4]:  user2 = UserFactory()
In [5]:  assert user.username != user2.username
In [6]: user3 = UserFactory(username='custom')
In [7]: user3.username
Out[7]: 'custom'

在这个 Django shell 会话中,我们将注意到如何使用UserFactory

  • 我们可以使用单个无参数调用创建新模型,UserFactory()

  • 每次调用都会导致唯一的用户名,assert user.username != user2.username

  • 我们可以通过提供参数来更改工厂使用的值,UserFactory(username='custom')

接下来,让我们创建一个QuestionFactory

创建 QuestionFactory

我们的许多测试将需要多个Question实例。但是,每个Question必须有一个用户。这可能会导致大量脆弱和冗长的代码。创建QuestionFactory将解决这个问题。

在前面的示例中,我们看到了如何使用factory.Sequence为每个新模型的属性赋予不同的值。Factory Boy 还提供了factory.SubFactory,其中我们可以指示字段的值是另一个工厂的结果。

让我们将QuestionFactory添加到django/qanda/factories.py中:

from unittest.mock import patch

import factory

from qanda.models import Question
from user.factories import UserFactory

class QuestionFactory(factory.DjangoModelFactory):
    title = factory.Sequence(lambda n: 'Question #%d' % n)
    question = 'what is a question?'
    user = factory.SubFactory(UserFactory)

    class Meta:
        model = Question

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        with patch('qanda.service.elasticsearch.Elasticsearch'):
            return super()._create(model_class, *args, **kwargs)

我们的QuestionFactoryUserFactory非常相似。它们有以下共同点:

  • 派生自factory.DjangoModelFactory

  • 有一个Meta

  • 使用factory.Sequence为字段提供自定义值

  • 有一个硬编码的值

有两个重要的区别:

  • QuestionFactoryuser字段使用SubFactory,为每个Question创建一个新的用户,该用户是使用UserFactory创建的。

  • QuestionFactory_create方法模拟了 Elasticsearch 服务,以便在创建模型时不会尝试连接到该服务。否则,它调用默认的_create()方法。

为了看到我们的QuestionFactory的实际应用,让我们为我们的DailyQuestionList视图编写一个单元测试。

创建一个视图的单元测试

在这一部分,我们将为我们的DailyQuestionList视图编写一个视图单元测试。

对视图进行单元测试意味着直接向视图传递一个请求,并断言响应是否符合我们的期望。由于我们直接将请求传递给视图,我们还需要直接传递视图通常会接收的任何参数,这些参数从请求的 URL 中解析出来。从 URL 路径中解析值是请求路由的责任,在视图单元测试中我们不使用它。

让我们来看看django/qanda/tests.py中的DailyQuestionListTestCase类:

from datetime import date

from django.test import TestCase, RequestFactory

from qanda.factories import QuestionFactory
from qanda.views import DailyQuestionList

QUESTION_CREATED_STRFTIME = '%Y-%m-%d %H:%M'

class DailyQuestionListTestCase(TestCase):
"""
Tests the DailyQuestionList view
"""
QUESTION_LIST_NEEDLE_TEMPLATE = '''
<li >
    <a href="/q/{id}" >{title}</a >
    by {username} on {date}
</li >
'''

REQUEST = RequestFactory().get(path='/q/2030-12-31')
TODAY = date.today()

def test_GET_on_day_with_many_questions(self):
    todays_questions = [QuestionFactory() for _ in range(10)]

    response = DailyQuestionList.as_view()(
        self.REQUEST,
        year=self.TODAY.year,
        month=self.TODAY.month,
        day=self.TODAY.day
    )

    self.assertEqual(200, response.status_code)
    self.assertEqual(10, response.context_data['object_list'].count())
    rendered_content = response.rendered_content
    for question in todays_questions:
        needle = self.QUESTION_LIST_NEEDLE_TEMPLATE.format(
            id=question.id,
            title=question.title,
            username=question.user.username,
            date=question.created.strftime(QUESTION_CREATED_STRFTIME)
        )
        self.assertInHTML(needle, rendered_content)

让我们更仔细地看一下我们见过的新 API:

  • RequestFactory().get(path=...): RequestFactory是一个用于创建测试视图的 HTTP 请求的实用工具。注意这里我们请求的path是任意的,因为它不会被用于路由。

  • DailyQuestionList.as_view()(...): 我们已经讨论过每个基于类的视图都有一个as_view()方法,它返回一个可调用对象,但我们以前没有使用过。在这里,我们传递请求、年、月和日来执行视图。

  • response.context_data['object_list'].count():我们的视图返回的响应仍然保留了它的上下文。我们可以使用这个上下文来断言视图是否工作正确,比起评估 HTML 更容易。

  • response.rendered_content: rendered_content属性让我们可以访问响应的渲染模板。

  • self.assertInHTML(needle, rendered_content): TestCase.assertInHTML()让我们可以断言一个 HTML 片段是否在另一个 HTML 片段中。assertInHTML()知道如何解析 HTML,不关心属性顺序或空白。在测试视图时,我们经常需要检查响应中是否存在特定的 HTML 片段。

现在我们已经为一个视图创建了一个单元测试,让我们看看通过为QuestionDetailView创建一个集成测试来创建一个视图的集成测试。

创建一个视图集成测试

视图集成测试使用与单元测试相同的django.test.TestCase类。集成测试将告诉我们我们的项目是否能够将请求路由到视图并返回正确的响应。集成测试请求将不得不通过项目配置的所有中间件和 URL 路由。为了帮助我们编写集成测试,Django 提供了TestCase.client

TestCase.clientTestCase提供的一个实用工具,让我们可以向我们的项目发送 HTTP 请求(它不能发送外部 HTTP 请求)。Django 会正常处理这些请求。client还为我们提供了方便的方法,比如client.login(),一种开始认证会话的方法。一个TestCase类也会在每个测试之间重置它的client

让我们在django/qanda/tests.py中为QuestionDetailView编写一个集成测试:

from django.test import TestCase

from qanda.factories import QuestionFactory
from user.factories import UserFactory

QUESTION_CREATED_STRFTIME = '%Y-%m-%d %H:%M'

class QuestionDetailViewTestCase(TestCase):
    QUESTION_DISPLAY_SNIPPET = '''
    <div class="question" >
      <div class="meta col-sm-12" >
        <h1 >{title}</h1 >
        Asked by {user} on {date}
      </div >
      <div class="body col-sm-12" >
        {body}
      </div >
    </div >'''
    LOGIN_TO_POST_ANSWERS = 'Login to post answers.'

    def test_logged_in_user_can_post_answers(self):
        question = QuestionFactory()

        self.assertTrue(self.client.login(
            username=question.user.username,
            password=UserFactory.password)
        )
        response = self.client.get('/q/{}'.format(question.id))
        rendered_content = response.rendered_content

        self.assertEqual(200, response.status_code)

         self.assertInHTML(self.NO_ANSWERS_SNIPPET, rendered_content)

        template_names = [t.name for t in response.templates]
        self.assertIn('qanda/common/post_answer.html', template_names)

        question_needle = self.QUESTION_DISPLAY_SNIPPET.format(
            title=question.title,
            user=question.user.username,
            date=question.created.strftime(QUESTION_CREATED_STRFTIME),
            body=QuestionFactory.question,
        )
        self.assertInHTML(question_needle, rendered_content)

在这个示例中,我们登录然后请求Question的详细视图。我们对结果进行多次断言以确认它是正确的(包括检查使用的模板的名称)。

让我们更详细地检查一些代码:

  • self.client.login(...): 这开始了一个认证会话。所有未来的请求都将作为该用户进行认证,直到我们调用client.logout()

  • self.client.get('/q/{}'.format(question.id)): 这使用我们的客户端发出一个 HTTP GET请求。不同于我们使用RequestFactory时,我们提供的路径是为了将我们的请求路由到一个视图(注意我们在测试中从未直接引用视图)。这返回了我们的视图创建的响应。

  • [t.name for t in response.templates]: 当客户端的响应渲染时,客户端会更新响应的使用的模板列表。在详细视图的情况下,我们使用了多个模板。为了检查我们是否显示了发布答案的 UI,我们将检查qanda/common/post_answer.html文件是否是使用的模板之一。

通过这种类型的测试,我们可以非常有信心地确认我们的视图在用户发出请求时是否有效。然而,这确实将测试与项目的配置耦合在一起。即使是来自第三方应用的视图,集成测试也是有意义的,以确认它们是否被正确使用。如果你正在开发一个库应用,你可能会发现最好使用单元测试。

接下来,让我们通过使用 Selenium 来测试我们的 Django 和前端代码是否都正确工作,创建一个实时服务器测试用例。

创建一个实时服务器集成测试

我们将编写的最后一种类型的测试是实时服务器集成测试。在这个测试中,我们将启动一个测试 Django 服务器,并使用 Selenium 控制 Google Chrome 向其发出请求。

Selenium 是一个工具,它具有许多语言的绑定(包括 Python),可以让你控制一个网页浏览器。这样你就可以测试真实浏览器在使用你的项目时的行为,因为你是用真实浏览器测试你的项目。

这种类型的测试有一些限制:

  • 实时测试通常需要按顺序运行

  • 很容易在测试之间泄漏状态。

  • 使用浏览器比TestCase.client()慢得多(浏览器会发出真正的 HTTP 请求)

尽管存在所有这些缺点,实时服务器测试在当前客户端网页应用如此强大的时代是一个非常宝贵的工具。

让我们首先设置 Selenium。

设置 Selenium

让我们通过使用pip来将 Selenium 添加到我们的项目中进行安装:

$pip install selenium==3.8.0

接下来,我们需要特定的 webdriver,告诉 Selenium 如何与 Chrome 通信。Google 在sites.google.com/a/chromium.org/chromedriver/提供了一个chromedriver。在我们的情况下,让我们把它保存在项目目录的根目录下。然后,让我们在django/conf/settings.py中添加该驱动程序的路径:

CHROMEDRIVER = os.path.join(BASE_DIR, '../chromedriver')

最后,请确保你的计算机上安装了 Google Chrome。如果没有,你可以在www.google.com/chrome/index.html下载它。

所有主要的浏览器都声称对 Selenium 有一定程度的支持。如果你不喜欢 Google Chrome,你可以尝试其他浏览器。有关详细信息,请参阅 Selenium 的文档(www.seleniumhq.org/about/platforms.jsp)。

使用 Django 服务器和 Selenium 进行测试

现在我们已经设置好了 Selenium,我们可以创建我们的实时服务器测试。当我们的项目有很多 JavaScript 时,实时服务器测试特别有用。然而,Answerly 并没有任何 JavaScript。然而,Django 的表单确实利用了大多数浏览器(包括 Google Chrome)支持的 HTML5 表单属性。我们仍然可以测试我们的代码是否正确地使用了这些功能。

在这个测试中,我们将检查用户是否可以提交一个空的问题。titlequestion字段应该被标记为required,这样如果这些字段为空,浏览器就不会提交表单。

让我们在django/qanda/tests.py中添加一个新的测试:

from django.contrib.staticfiles.testing import StaticLiveServerTestCase

from selenium.webdriver.chrome.webdriver import WebDriver

from user.factories import UserFactory

class AskQuestionTestCase(StaticLiveServerTestCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.selenium = WebDriver(executable_path=settings.CHROMEDRIVER)
        cls.selenium.implicitly_wait(10)

    @classmethod
    def tearDownClass(cls):
        cls.selenium.quit()
        super().tearDownClass()

    def setUp(self):
        self.user = UserFactory()

    def test_cant_ask_blank_question(self):
        initial_question_count = Question.objects.count()

        self.selenium.get('%s%s' % (self.live_server_url, '/user/login'))

        username_input = self.selenium.find_element_by_name("username")
        username_input.send_keys(self.user.username)
        password_input = self.selenium.find_element_by_name("password")
        password_input.send_keys(UserFactory.password)
        self.selenium.find_element_by_id('log_in').click()

        self.selenium.find_element_by_link_text("Ask").click()
        ask_question_url = self.selenium.current_url
        submit_btn = self.selenium.find_element_by_id('ask')
        submit_btn.click()
        after_empty_submit_click = self.selenium.current_url

        self.assertEqual(ask_question_url, after_empty_submit_click)
        self.assertEqual(initial_question_count, Question.objects.count())

让我们来看看这个测试中引入的一些新的 Django 特性。然后,我们将审查我们的 Selenium 代码:

  • class AskQuestionTestCase(StaticLiveServerTestCase): StaticLiveServerTestCase启动了一个 Django 服务器,并确保静态文件被正确地提供。你不必运行python manage.py collectstatic。文件将被正确地路由,就像你运行python manage.py runserver一样。

  • def setUpClass(cls): 所有的 Django 测试用例都支持setUpClass()setup()teardown()teardownClass()方法,就像往常一样。setUpClasstearDownClass()每个TestCase只运行一次(分别在之前和之后)。这使它们非常适合昂贵的操作,比如用 Selenium 连接到 Google Chrome。

  • self.live_server_url:这是实时服务器的 URL。

Selenium 允许我们使用 API 与浏览器进行交互。本书不侧重于 Selenium,但让我们来介绍一些WebDriver类的关键方法:

  • cls.selenium = WebDriver(executable_path=settings.CHROMEDRIVER): 这实例化了一个 WebDriver 实例,其中包含到ChromeDriver可执行文件的路径(我们在前面的设置 Selenium部分中下载了)。我们将ChromeDriver可执行文件的路径存储在设置中,以便在这里轻松引用它。

  • selenium.find_element_by_name(...): 这返回一个其name属性与提供的参数匹配的 HTML 元素。name属性被所有值由表单处理的<input>元素使用,因此对于数据输入特别有用。

  • self.selenium.find_element_by_id(...): 这与前面的步骤类似,只是通过其id属性查找匹配的元素。

  • self.selenium.current_url: 这是浏览器的当前 URL。这对于确认我们是否在预期的页面上很有用。

  • username_input.send_keys(...): send_keys()方法允许我们将传递的字符串输入到 HTML 元素中。这对于<input type='text'><input type='password'>元素特别有用。

  • submit_btn.click(): 这会触发对元素的点击。

这个测试以用户身份登录,尝试提交表单,并断言仍然在同一个页面上。不幸的是,虽然带有空的必填input元素的表单不会自行提交,但没有 API 直接确认这一点。相反,我们确认我们没有提交,因为浏览器仍然在与之前点击提交之前相同的 URL 上(根据self.selenium.current_url)。

总结

在本章中,我们学习了如何在 Django 项目中测量代码覆盖率,以及如何编写四种不同类型的测试——用于测试任何函数或类的单元测试,包括模型和表单;以及用于使用RequestFactory测试视图的视图单元测试。我们介绍了如何查看集成测试,用于测试请求路由到视图并返回正确响应,以及用于测试客户端和服务器端代码是否正确配合工作的实时服务器集成测试。

现在我们有了一些测试,让我们将 Answerly 部署到生产环境中。

第九章:部署 Answerly

在前一章中,我们了解了 Django 的测试 API,并为 Answerly 编写了一些测试。作为最后一步,让我们使用 Apache Web 服务器和 mod_wsgi 在 Ubuntu 18.04(Bionic Beaver)服务器上部署 Answerly。

本章假设您的服务器上有代码位于/answerly下,并且能够推送更新到该代码。您将在本章中对代码进行一些更改。尽管进行了更改,但您需要避免养成直接在生产环境中进行更改的习惯。例如,您可能正在使用版本控制系统(如 git)来跟踪代码的更改。然后,您可以在本地工作站上进行更改,将其推送到远程存储库(例如,托管在 GitHub 或 GitLab 上),并在服务器上拉取它们。这些代码在 GitHub 的版本控制中可用(github.com/tomarayn/Answerly)。

在本章中,我们将做以下事情:

  • 组织我们的配置代码以分离生产和开发设置

  • 准备我们的 Ubuntu Linux 服务器

  • 使用 Apache 和 mod_wsgi 部署我们的项目

  • 看看 Django 如何让我们将项目部署为十二要素应用程序

让我们开始组织我们的配置,将开发和生产设置分开。

组织生产和开发的配置

到目前为止,我们一直保留了一个requirements文件和一个settings.py。这使得开发变得方便。但是,我们不能在生产中使用我们的开发设置。

当前的最佳实践是为每个环境单独创建一个文件。然后,每个环境的文件都导入具有共享值的公共文件。我们将使用这种模式来处理我们的要求和设置文件。

让我们首先拆分我们的要求文件。

拆分我们的要求文件

首先,让我们在项目的根目录创建requirements.common.txt

django<2.1
psycopg2==2.7.3.2
django-markdownify==0.2.2
django-crispy-forms==1.7.0
elasticsearch==6.0.0

无论我们的环境如何,这些都是我们运行 Answerly 所需的共同要求。然而,这个requirements文件从未直接使用过。我们的开发和生产要求文件将会引用它。

接下来,让我们在requirements.development.txt中列出我们的开发要求:

-r requirements.common.txt
ipython==6.2.1
coverage==4.4.2
factory-boy==2.9.2
selenium==3.8.0

前面的文件将安装requirements.common.txt中的所有内容(感谢-r),以及我们的测试包(coveragefactory-boyselenium)。我们将这些文件放在我们的开发文件中,因为我们不希望在生产环境中运行这些测试。如果我们在生产环境中运行测试,那么我们可能会将它们移动到requirements.common.txt中。

对于生产环境,我们的requirements.production.txt文件非常简单:

-r requirements.common.txt

Answerly 不需要任何特殊的软件包。但是,为了清晰起见,我们仍将创建一个。

要在生产环境中安装软件包,我们现在执行以下命令:

$ pip install -r requirements.production.txt

接下来,让我们按类似的方式拆分设置文件。

拆分我们的设置文件

同样,我们将遵循当前 Django 最佳实践,将我们的设置文件分成三个文件:common_settings.pyproduction_settings.pydev_settings.py

创建 common_settings.py

我们将通过重命名我们当前的settings.py文件并进行一些更改来创建common_settings.py

让我们将DEBUG = False更改为不会意外处于调试模式的新设置文件。然后,让我们通过更新SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')来从环境变量中获取密钥。

让我们还添加一个新的设置,STATIC_ROOTSTATIC_ROOT是 Django 将从我们安装的应用程序中收集所有静态文件的目录,以便更容易地提供它们:

STATIC_ROOT = os.path.join(BASE_DIR, 'static_root')

在数据库配置中,我们可以删除所有凭据并保留ENGINE的值(以明确表明我们打算在任何地方使用 Postgres):

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
    }
}

接下来,让我们创建一个开发设置文件。

创建 dev_settings.py

我们的开发设置将在django/config/dev_settings.py中。让我们逐步构建它。

首先,我们将从common_settings中导入所有内容:

from config.common_settings import *

然后,我们将覆盖一些设置:

DEBUG = True
SECRET_KEY = 'some secret'

在开发中,我们总是希望以调试模式运行。此外,我们可以放心地硬编码一个密钥,因为我们知道它不会在生产中使用:

DATABASES['default'].update({
    'NAME': 'mymdb',
    'USER': 'mymdb',
    'PASSWORD': 'development',
    'HOST': 'localhost',
    'PORT': '5432',
})

由于我们的开发数据库是本地的,我们可以在设置中硬编码值,以使设置更简单。如果您的数据库不是本地的,请避免将密码检入版本控制,并像在生产中一样使用os.getenv()

我们还可以添加我们的开发专用应用程序可能需要的更多设置。例如,在第五章中,使用 Docker 部署,我们有缓存和 Django Debug Toolbar 应用程序的设置。Answerly 目前不使用这些,所以我们不会包含这些设置。

接下来,让我们添加生产设置。

创建 production_settings.py

让我们在django/config/production_settings.py中创建我们的生产设置。

production_settings.py类似于dev_settings.py,但通常使用os.getenv()从环境变量中获取值。这有助于我们将机密(例如密码、API 令牌等)排除在版本控制之外,并将设置与特定服务器分离。我们将在Factor 3 – config部分再次提到这一点。

from config.common_settings import * 
DEBUG = False
assert SECRET_KEY is not None, (
    'Please provide DJANGO_SECRET_KEY '
    'environment variable with a value')
ALLOWED_HOSTS += [
    os.getenv('DJANGO_ALLOWED_HOSTS'),
]

首先,我们导入通用设置。出于谨慎起见,我们确保调试模式关闭。

设置SECRET_KEY对于我们的系统保持安全至关重要。我们使用assert来防止 Django 在没有SECRET_KEY的情况下启动。common_settings.py文件应该已经从环境变量中设置了它。

生产网站将在localhost之外的域上访问。我们将通过将DJANGO_ALLOWED_HOSTS环境变量附加到ALLOWED_HOSTS列表来告诉 Django 我们正在提供哪些其他域。

接下来,让我们更新数据库配置:

DATABASES['default'].update({
    'NAME': os.getenv('DJANGO_DB_NAME'),
    'USER': os.getenv('DJANGO_DB_USER'),
    'PASSWORD': os.getenv('DJANGO_DB_PASSWORD'),
    'HOST': os.getenv('DJANGO_DB_HOST'),
    'PORT': os.getenv('DJANGO_DB_PORT'),
})

我们使用环境变量的值更新了数据库配置。

现在我们的设置已经整理好了,让我们准备我们的服务器。

准备我们的服务器

现在我们的代码已经准备好投入生产,让我们准备我们的服务器。在本章中,我们将使用 Ubuntu 18.04(Bionic Beaver)。如果您使用其他发行版,则某些软件包名称可能不同,但我们将采取的步骤将是相同的。

为了准备我们的服务器,我们将执行以下步骤:

  1. 安装所需的操作系统软件包

  2. 设置 Elasticsearch

  3. 创建数据库

让我们从安装我们需要的软件包开始。

安装所需的软件包

要在我们的服务器上运行 Answerly,我们需要确保正确的软件正在运行。

让我们创建一个我们将在ubuntu/packages.txt中需要的软件包列表:

python3
python3-pip
virtualenv

apache2
libapache2-mod-wsgi-py3

postgresql
postgresql-client

openjdk-8-jre-headless

前面的代码将为以下内容安装软件包:

  • 完全支持 Python 3

  • Apache HTTP 服务器

  • mod_wsgi,用于运行 Python Web 应用程序的 Apache HTTP 模块

  • PostgreSQL 数据库服务器和客户端

  • Java 8,Elasticsearch 所需

要安装软件包,请运行以下命令:

$ sudo apt install -y $(cat /answerly/ubuntu/packages.txt)

接下来,我们将把我们的 Python 软件包安装到虚拟环境中:

$ mkvirutalenv /opt/answerly.venv
$ source /opt/answerly.venv/bin/activate
$ pip install -r /answerly/requirements.production.txt

太好了!现在我们有了所有的软件包,我们需要设置 Elasticsearch。不幸的是,Ubuntu 没有提供最新版本的 Elasticsearch,所以我们将直接从 Elastic 安装它。

配置 Elasticsearch

我们将直接从 Elastic 获取 Elasticsearch。Elastic 通过在具有 Ubuntu 兼容的.deb软件包的服务器上运行来简化此过程(如果对您更方便,Elastic 还提供并支持 RPM)。最后,我们必须记住将 Elasticsearch 重新绑定到 localhost,否则我们将在开放的公共端口上运行一个不安全的服务器。

安装 Elasticsearch

让我们通过运行以下三个命令将 Elasticsearch 添加到我们信任的存储库中:

$ wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
$ sudo apt install apt-transport-https
$ echo "deb https://artifacts.elastic.co/packages/6.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-6.x.list
$ sudo apt update

前面的命令执行以下四个步骤:

  1. 将 Elastic GPG 密钥添加到受信任的 GPG 密钥列表中

  2. 通过安装apt-transport-https软件包,确保apt通过HTTPS获取软件包

  3. 添加一个新的源文件,列出 Elastic 软件包服务器,以便apt知道如何从 Elastic 获取 Elasticsearch 软件包

  4. 更新可用软件包列表(现在将包括 Elasticsearch)

现在我们有了 Elasticsearch,让我们安装它:

$ sudo apt install elasticsearch

接下来,让我们配置 Elasticsearch。

运行 Elasticsearch

默认情况下,Elasticsearch 配置为绑定到公共 IP 地址,并且不包括身份验证。

要更改 Elasticsearch 运行的地址,让我们编辑/etc/elasticsearch/elasticsearch.yml。找到带有network.host的行并更新如下:

network.host: 127.0.0.1

如果您不更改network.host设置,那么您将在公共 IP 上运行没有身份验证的 Elasticsearch。您的服务器被黑客攻击将是不可避免的。

最后,我们要确保 Ubuntu 启动 Elasticsearch 并保持其运行。为了实现这一点,我们需要告诉 systemd 启动 Elasticsearch:

$ sudo systemctl daemon-reload
$ sudo systemctl enable elasticsearch.service
$ sudo systemctl start elasticsearch.service

上述命令执行以下三个步骤:

  1. 完全重新加载 systemd,然后它将意识到新安装的 Elasticsearch 服务

  2. 启用 Elasticsearch 服务,以便在服务器启动时启动(以防重新启动或关闭)

  3. 启动 Elasticsearch

如果您需要停止 Elasticsearch 服务,可以使用systemctlsudo systemctl stop elasticsearch.service

现在我们已经运行了 Elasticsearch,让我们配置数据库。

创建数据库

Django 支持迁移,但不能自行创建数据库或数据库用户。我们现在将编写一个脚本来为我们执行这些操作。

让我们将数据库创建脚本添加到我们的项目中的postgres/make_database.sh

#!/usr/bin/env bash

psql -v ON_ERROR_STOP=1 <<-EOSQL
    CREATE DATABASE $DJANGO_DB_NAME;
    CREATE USER $DJANGO_DB_USER;
    GRANT ALL ON DATABASE $DJANGO_DB_NAME to "$DJANGO_DB_USER";
    ALTER USER $DJANGO_DB_USER PASSWORD '$DJANGO_DB_PASSWORD';
    ALTER USER $DJANGO_DB_USER CREATEDB;
EOSQL

要创建数据库,请运行以下命令:

$ sudo su postgres
$ export DJANGO_DB_NAME=answerly
$ export DJANGO_DB_USER=answerly
$ export DJANGO_DB_PASSWORD=password
$ bash /answerly/postgres/make_database.sh

上述命令执行以下三件事:

  1. 切换到postgres用户,该用户被信任可以连接到 Postgres 数据库而无需任何额外的凭据。

  2. 设置环境变量,描述我们的新数据库用户和模式。记得将password的值更改为一个强密码。

  3. 执行make_database.sh脚本。

现在我们已经配置了服务器,让我们使用 Apache 和 mod_wsgi 部署 Answerly。

使用 Apache 部署 Answerly

我们将使用 Apache 和 mod_wsgi 部署 Answerly。mod_wsgi 是一个开源的 Apache 模块,允许 Apache 托管实现Web 服务器网关接口WSGI)规范的 Python 程序。

Apache web 服务器是部署 Django 项目的众多优秀选项之一。许多组织都有一个运维团队,他们部署 Apache 服务器,因此使用 Apache 可以消除在项目中使用 Django 时的一些组织障碍。Apache(带有 mod_wsgi)还知道如何运行多个 web 应用程序并在它们之间路由请求,与我们在第五章中的先前配置不同,使用 Docker 部署,我们需要一个反向代理(NGINX)和 web 服务器(uWSGI)。使用 Apache 的缺点是它比 uWSGI 使用更多的内存。此外,Apache 没有一种将环境变量传递给我们的 WSGI 进程的方法。总的来说,使用 Apache 进行部署可以成为 Django 开发人员工具中非常有用和重要的一部分。

要部署,我们将执行以下操作:

  1. 创建虚拟主机配置

  2. 更新wsgi.py

  3. 创建一个环境配置文件

  4. 收集静态文件

  5. 迁移数据库

  6. 启用虚拟主机

让我们为我们的 Apache web 服务器开始创建一个虚拟主机配置。

创建虚拟主机配置

一个 Apache web 服务器可以使用来自不同位置的不同技术托管许多网站。为了保持每个网站的独立性,Apache 提供了定义虚拟主机的功能。每个虚拟主机是一个逻辑上独立的站点,可以为一个或多个域和端口提供服务。

由于 Apache 已经是一个很好的 Web 服务器,我们将使用它来提供静态文件。提供静态文件的 Web 服务器和我们的 mod_wsgi 进程不会竞争,因为它们将作为独立的进程运行,这要归功于 mod_wsgi 的守护进程模式。mod_wsgi 守护进程模式意味着 Answerly 将在与 Apache 的其余部分分开的进程中运行。Apache 仍然负责启动/停止这些进程。

让我们在项目的apache/answerly.apache.conf下添加 Apache 虚拟主机配置:

<VirtualHost *:80>

    WSGIDaemonProcess answerly \
        python-home=/opt/answerly.venv \
        python-path=/answerly/django \
        processes=2 \
        threads=15
    WSGIProcessGroup answerly
    WSGIScriptAlias / /answerly/django/config/wsgi.py

    <Directory /answerly/django/config>
        <Files wsgi.py>
            Require all granted
        </Files>
    </Directory>

    Alias /static/ /answerly/django/static_root
    <Directory /answerly/django/static_root>
        Require all granted
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

让我们仔细看一下其中的一些指令:

  • <VirtualHost *:80>:这告诉 Apache,直到关闭的</VirtualHost>标签之前的所有内容都是虚拟主机定义的一部分。

  • WSGIDaemonProcess:这配置 mod_wsgi 以守护进程模式运行。守护进程将被命名为answerlypython-home选项定义了守护进程将使用的 Python 进程的虚拟环境。python-path选项允许我们将我们的模块添加到守护进程的 Python 中,以便它们可以被导入。processesthreads选项告诉 Apache 要维护多少个进程和线程。

  • WSGIProcessGroup:这将虚拟主机与 Answerly mod_wsgi 守护进程关联起来。记住要保持WSGIDaemonProcess名称和WSGIProcessGroup名称相同。

  • WSGIScriptAlias:这描述了应该将哪些请求路由到哪个 WSGI 脚本。在我们的情况下,所有请求都应该转到 Answerly 的 WSGI 脚本。

  • <Directory /answerly/django/config>:这个块允许所有用户访问我们的 WSGI 脚本。

  • Alias /static/ /answerly/django/static_root:这将任何以/static/开头的请求路由到我们的静态文件根目录,而不是 mod_wsgi。

  • <Directory /answerly/django/static_root>:这个块允许用户访问static_root中的文件。

  • ErrorLogCustomLog:它们描述了 Apache 应该将其日志发送到这个虚拟主机的位置。在我们的情况下,我们希望将其记录在 Apache 的log目录中(通常是/var/log/apache)。

我们现在已经配置 Apache 来运行 Answerly。然而,如果你比较一下你的 Apache 配置和第五章中的 uWSGI 配置,使用 Docker 部署,你会注意到一个区别。在 uWSGI 配置中,我们提供了我们的production_settings.py依赖的环境变量。然而,mod_wsgi 并没有为我们提供这样的功能。相反,我们将更新django/config/wsgi.py,以提供production_settings.py需要的环境变量。

更新 wsgi.py 以设置环境变量

现在,我们将更新django/config/wsgi.py,以提供production_settings.py想要的环境变量,但 mod_wsgi 无法提供。我们还将更新wsgi.py,在启动时读取配置文件,然后自己设置环境变量。这样,我们的生产设置不会与 mod_wsgi 或配置文件耦合。

让我们更新django/config/wsgi.py

import os
import configparser
from django.core.wsgi import get_wsgi_application

if not os.environ.get('DJANGO_SETTINGS_MODULE'):
    parser = configparser.ConfigParser()
    parser.read('/etc/answerly/answerly.ini')
    for name, val in parser['mod_wsgi'].items():
        os.environ[name.upper()] = val

application = get_wsgi_application()

在更新的wsgi.py中,我们检查是否有DJANGO_SETTINGS_MODULE环境变量。如果没有,我们解析我们的配置文件并设置环境变量。我们的for循环将变量的名称转换为大写,因为ConfigParser默认会将它们转换为小写

接下来,让我们创建我们的环境配置文件。

创建环境配置文件

我们将把环境配置存储在/etc/answerly/answerly.ini下。我们不希望它存储在/answerly下,因为它不是我们代码的一部分。这个文件描述了只有这台服务器的设置。我们永远不应该将这个文件提交到版本控制中。

让我们在服务器上创建/etc/answerly/answerly.ini

[mod_wsgi]
DJANGO_ALLOWED_HOSTS=localhost
DJANGO_DB_NAME=answerly
DJANGO_DB_USER=answerly
DJANGO_DB_PASSWORD=password
DJANGO_DB_HOST=localhost
DJANGO_DB_PORT=5432
DJANGO_ES_INDEX=answerly
DJANGO_ES_HOST=localhost
DJANGO_ES_PORT=9200
DJANGO_LOG_FILE=/var/log/answerly/answerly.log
DJANGO_SECRET_KEY=a large random value
DJANGO_SETTINGS_MODULE=config.production_settings

以下是关于这个文件的两件事需要记住的:

  • 记得将DJANGO_DB_PASSWORD设置为你在运行make_database.sh脚本时设置的相同值。记得确保这个密码是强大和保密的

  • 记得设置一个强大的DJANGO_SECRET_KEY值。

我们现在应该已经为 Apache 设置好了环境。接下来,让我们迁移数据库。

迁移数据库

我们在之前的步骤中为 Answerly 创建了数据库,但我们没有创建表。现在让我们使用 Django 内置的迁移工具迁移数据库。

在服务器上,我们希望执行以下命令:

$ cd /answerly/django
$ source /opt/answerly.venv/bin/activate
$ export DJANGO_SECRET_KEY=anything
$ export DJANGO_DB_HOST=127.0.0.1 
$ export DJANGO_DB_PORT=5432 
$ export DJANGO_LOG_FILE=/var/log/answerly/answerly.log 
$ export DJANGO_DB_USER=myqa 
$ export DJANGO_DB_NAME=myqa 
$ export DJANGO_DB_PASSWORD=password 
$ sudo python3 manage.py migrate --settings=config.production_settings

我们的django/config/production_settings.py将要求我们提供带有值的DJANGO_SECRET_KEY,但在这种情况下不会使用它。但是,为DJANGO_DB_PASSWORD和其他DJANGO_DB变量提供正确的值至关重要。

一旦我们的migrate命令返回成功,那么我们的数据库将拥有我们需要的所有表。

接下来,让我们让我们的静态(JavaScript/CSS/图像)文件对我们的用户可用。

收集静态文件

在我们的虚拟主机配置中,我们配置了 Apache 来提供我们的静态(JS,CSS,图像等)文件。为了让 Apache 提供这些文件,我们需要将它们全部收集到一个父目录下。让我们使用 Django 内置的manage.py collectstatic命令来做到这一点。

在服务器上,让我们运行以下命令:

$ cd /answerly/django
$ source /opt/answerly.venv/bin/activate
$ export DJANGO_SECRET_KEY=anything
$ export DJANGO_LOG_FILE=/var/log/answerly/answerly.log
$ sudo python3 manage.py collectstatic --settings=config.production_settings --no-input

上述命令将从所有已安装的应用程序复制静态文件到/answerly/django/static_root(根据production_settings.py中的STATIC_ROOT定义)。我们的虚拟主机配置告诉 Apache 直接提供这些文件。

现在,让我们告诉 Apache 开始提供 Answerly。

启用 Answerly 虚拟主机

为了让 Apache 向用户提供 Answerly,我们需要启用我们在上一节创建的虚拟主机配置,创建虚拟主机配置。要在 Apache 中启用虚拟主机,我们将在虚拟主机配置上添加一个软链接指向 Apache 的site-enabled目录,并告诉 Apache 重新加载其配置。

首先,让我们将我们的软链接添加到 Apache 的site-enabled目录:

$ sudo ln -s /answerly/apache/answerly.apache.conf /etc/apache/site-enabled/000-answerly.conf

我们使用001作为软链接的前缀来控制我们的配置加载顺序。Apache 按字符顺序加载站点配置文件(例如,在 Unicode/ASCII 编码中,Ba之前)。前缀用于使顺序更加明显。

Apache 经常与默认站点捆绑在一起。查看/etc/apache/sites-enabled/以查找不想运行的站点。由于其中的所有内容都应该是软链接,因此可以安全地删除它们。

要激活虚拟主机,我们需要重新加载 Apache 的配置:

$ sudo systemctl reload  apache2.service

恭喜!您已经在服务器上部署了 Answerly。

快速回顾本节

到目前为止,在本章中,我们已经了解了如何使用 Apache 和 mod_wsgi 部署 Django。首先,我们通过从 Ubuntu 和 Elastic(用于 Elasticsearch)安装软件包来配置了我们的服务器。然后,我们配置了 Apache 以将 Answerly 作为虚拟主机运行。我们的 Django 代码将由 mod_wsgi 执行。

到目前为止,我们已经看到了两种非常不同的部署方式,一种使用 Docker,一种使用 Apache 和 mod_wsgi。尽管是非常不同的环境,但我们遵循了许多相似的做法。让我们看看 Django 最佳实践是如何符合流行的十二要素应用方法论的。

将 Django 项目部署为十二要素应用

十二要素应用文档解释了一种开发 Web 应用和服务的方法论。这些原则是由 Adam Wiggins 和其他人在 2011 年主要基于他们在 Heroku(一家知名的平台即服务提供商)的经验而记录的。Heroku 是最早帮助开发人员构建易于扩展的 Web 应用和服务的 PaaS 之一。自发布以来,十二要素应用的原则已经塑造了很多关于如何构建和部署 SaaS 应用(如 Web 应用)的思考。

十二要素提供了许多好处,如下:

  • 使用声明性格式来简化自动化和入职

  • 强调在部署环境中的可移植性

  • 鼓励生产/开发环境的一致性和持续部署和集成

  • 简化扩展而无需重新架构

然而,在评估十二因素时,重要的是要记住它们与 Heroku 的部署方法紧密相关。并非所有平台(或 PaaS 提供商)都有完全相同的方法。这并不是说十二因素是正确的,其他方法是错误的,反之亦然。相反,十二因素是要牢记的有用原则。您应该根据需要调整它们以帮助您的项目,就像您对待任何方法论一样。

单词应用程序的十二因素用法与 Django 的可用性不同:

  • Django 项目相当于十二因素应用程序

  • Django 应用程序相当于十二因素库

在本节中,我们将研究十二个因素的每个含义以及它们如何应用到您的 Django 项目中。

因素 1 - 代码库

“一个代码库在修订控制中跟踪,多个部署” - 12factor.net

这个因素强调了以下两点:

  • 所有代码都应该在版本控制的代码存储库(repo)中进行跟踪

  • 每次部署都应该能够引用该存储库中的单个版本/提交

这意味着当我们遇到错误时,我们确切地知道是哪个代码版本负责。如果我们的项目跨越多个存储库,十二因素方法要求共享代码被重构为库并作为依赖项进行跟踪(参见因素 2 - 依赖关系部分)。如果多个项目使用同一个存储库,那么它们应该被重构为单独的存储库(有时称为多存储库)。自十二因素首次发布以来,多存储库与单存储库(一个存储库用于多个项目)的使用已经越来越受到争议。一些大型项目发现使用单存储库有益处。其他项目通过多个存储库取得了成功。

基本上,这个因素努力确保我们知道在哪个环境中运行什么。

我们可以以可重用的方式编写我们的 Django 应用程序,以便它们可以作为使用pip安装的库进行托管(多存储库样式)。或者,您可以通过修改 Django 项目的 Python 路径,将所有 Django 项目和应用程序托管在同一个存储库(单存储库)中。

因素 2 - 依赖关系

“明确声明和隔离依赖关系” - 12 factor.net

十二因素应用程序不应假设其环境的任何内容。项目使用的库和工具必须由项目声明并作为部署的一部分安装(参见因素 5 - 构建、发布和运行部分)。所有运行的十二因素应用程序都应该相互隔离。

Django 项目受益于 Python 丰富的工具集。 “在 Python 中,这些步骤有两个单独的工具 - Pip 用于声明,Virtualenv 用于隔离”(12factor.net/dependencies)。在 Answerly 中,我们还使用了一系列我们用apt安装的 Ubuntu 软件包。

因素 3 - 配置

将配置存储在环境中 - 12factor.net

十二因素应用程序方法提供了一个有用的配置定义:

“应用程序的配置是在部署之间可能变化的所有内容(暂存、生产、开发环境等)” - 12factor.net/config

十二因素应用程序方法还鼓励使用环境变量来传递配置值给我们的代码。这意味着如果出现问题,我们可以测试确切部署的代码(由因素 1 提供)以及使用的确切配置。我们还可以通过使用不同的配置部署相同的代码来检查错误是配置问题还是代码问题。

在 Django 中,我们的配置由我们的settings.py文件引用。在 MyMDB 和 Answerly 中,我们看到了一些常见的配置值,如SECRET_KEY、数据库凭据和 API 密钥(例如 AWS 密钥),通过环境变量传递。

然而,这是一个领域,Django 最佳实践与十二要素应用的最严格解读有所不同。Django 项目通常为分别用于分阶段、生产和本地开发的设置文件创建一个单独的设置文件,大多数设置都是硬编码的。主要是凭据和秘密作为环境变量传递。

Factor 4 – 后备服务

"将后备服务视为附加资源" – 12factor.net

十二要素应用不应关心后备服务(例如数据库)的位置,并且应始终通过 URL 访问它。这样做的好处是我们的代码不与特定环境耦合。这种方法还允许我们架构的每个部分独立扩展。

在本章中部署的 Answerly 与其数据库位于同一服务器上。然而,我们没有使用本地身份验证机制,而是向 Django 提供了主机、端口和凭据。这样,我们可以将数据库移动到另一台服务器上,而不需要更改任何代码。我们只需要更新我们的配置。

Django 的编写假设我们会将大多数服务视为附加资源(例如,大多数数据库文档都是基于这一假设)。在使用第三方库时,我们仍然需要遵循这一原则。

Factor 5 – 构建、发布和运行

"严格分离构建和运行阶段" – 12factor.net

十二要素方法鼓励将部署分为三个明确的步骤:

  1. 构建:代码和依赖项被收集到一个单一的捆绑包中(一个构建

  2. 发布:构建与配置组合在一起,准备执行

  3. 运行:组合构建和配置的执行位置

十二要素应用还要求每个发布都有一个唯一的 ID,以便可以识别它。

这种部署细节已经超出了 Django 的范围,对这种严格的三步模型的遵循程度有各种各样。在第五章中看到的使用 Django 和 Docker 的项目可能会非常严格地遵循这一原则。MyMDB 有一个清晰的构建,所有依赖项都捆绑在 Docker 镜像中。然而,在本章中,我们从未进行捆绑构建。相反,我们在代码已经在服务器上之后安装依赖项(运行pip install)。许多项目都成功地使用了这种简单的模型。然而,随着项目规模的扩大,这可能会引起复杂性。Answerly 的部署展示了十二要素原则如何可以被弯曲,但对于某些项目仍然有效。

Factor 6 – 进程

"将应用程序作为一个或多个无状态进程执行" – 12factor.net

这一因素的重点是应用进程应该是无状态的。每个任务都是在不依赖前一个任务留下数据的情况下执行的。相反,状态应该存储在后备服务中(参见Factor 4 – 后备服务部分),比如数据库或外部缓存。这使得应用能够轻松扩展,因为所有进程都同样有资格处理请求。

Django 是围绕这一假设构建的。即使是会话,用户的登录状态也不是保存在进程中,而是默认保存在数据库中。视图类的实例永远不会被重用。Django 接近违反这一点的唯一地方是缓存后端之一(本地内存缓存)。然而,正如我们讨论过的,那是一个低效的后端。通常,Django 项目会为它们的缓存使用一个后备服务(例如 memcached)。

Factor 7 – 端口绑定

"通过端口绑定导出服务" – 12factor.net

这个因素的重点是我们的进程应该通过其端口直接访问。访问一个项目应该是向app.example.com:1234发送一个正确形成的请求。此外,十二要素应用程序不应该作为 Apache 模块或 Web 服务器容器运行。如果我们的项目需要解析 HTTP 请求,应该使用库(参见因素 2-依赖部分)来解析它们。

Django 遵循这个原则的部分。用户通过 HTTP 端口使用 HTTP 访问 Django 项目。与十二要素有所不同的是,Django 的一个方面几乎总是作为 Web 服务器的子进程运行(无论是 Apache、uWSGI 还是其他什么)。进行端口绑定的是 Web 服务器,而不是 Django。然而,这种微小的差异并没有阻止 Django 项目有效地扩展。

因素 8-并发

“通过进程模型扩展”- 12factor.net

十二要素应用程序的原则侧重于扩展(对于像 Heroku 这样的 PaaS 提供商来说是一个重要的关注点)。在因素 8 中,我们看到之前做出的权衡和决策如何帮助项目扩展。

由于项目作为无状态进程运行(参见因素 6-进程部分),作为端口(参见因素 7-端口绑定部分)可用,并发性只是拥有更多进程(跨一个或多个机器)的问题。进程不需要关心它们是否在同一台机器上,因为任何状态(比如问题的答案)都存储在后备服务(参见因素 4-后备服务部分)中,比如数据库。因素 8 告诉我们要相信 Unix 进程模型来运行服务,而不是创建守护进程或创建 PID 文件。

由于 Django 项目作为 Web 服务器的子进程运行,它们经常遵循这个原则。需要扩展的 Django 项目通常使用反向代理(例如 Nginx)和轻量级 Web 服务器(例如 uWSGI 或 Gunicorn)的组合。Django 项目不直接关注进程的管理方式,而是遵循它们正在使用的 Web 服务器的最佳实践。

因素 9-可处置性

“通过快速启动和优雅关闭来最大限度地提高鲁棒性”- 12factor.net

可处置性因素有两个部分。首先,十二要素应用程序应该能够在进程启动后不久开始处理请求。记住,所有它的依赖关系(参见因素 2-依赖部分)已经被安装(参见因素 5-构建、发布和运行部分)。十二要素应用程序应该处理进程停止或优雅关闭。进程不应该使十二要素应用程序处于无效状态。

Django 项目能够优雅地关闭,因为 Django 默认会将每个请求包装在一个原子事务中。如果一个 Django 进程(无论是由 uWSGI、Apache 还是其他任何东西管理的)在处理请求时停止,事务将永远不会被提交。数据库将放弃该事务。当我们处理其他后备服务(例如 S3 或 Elasticsearch)不支持事务时,我们必须确保在设计中考虑到这一点。

因素 10-开发/生产对等性

“尽量使开发、分期和生产尽可能相似”- 12factor.net

十二要素应用程序运行的所有环境应尽可能相似。当十二要素应用程序是一个简单的进程时(参见因素 6-进程部分),这就容易得多。这还包括十二要素应用程序使用的后备服务(参见因素 4-后备服务部分)。例如,十二要素应用程序的开发环境应该包括与生产环境相同的数据库。像 Docker 和 Vagrant 这样的工具可以使今天实现这一点变得更加容易。

Django 的一般最佳实践是在开发和生产中使用相同的数据库(和其他后端服务)。在本书中,我们一直在努力做到这一点。然而,Django 社区通常在开发中使用manage.py runserver命令,而不是运行 uWSGI 或 Apache。

11 因素 - 日志

"将日志视为事件流" - 12factor.net

日志应该只作为无缓冲的stdout流输出,十二因素应用程序永远不会关心其输出流的路由或存储12factor.net/logs)。当进程运行时,它应该只输出无缓冲的内容到stdout。然后启动进程的人(无论是开发人员还是生产服务器的 init 进程)可以适当地重定向该流。

Django 项目通常使用 Python 的日志模块。这可以支持写入日志文件或输出无缓冲流。一般来说,Django 项目会追加到一个文件中。该文件可以单独处理或旋转(例如,使用logrotate实用程序)。

12 因素 - 管理流程

"将管理/管理任务作为一次性进程运行" - 12factor.net

所有项目都需要不时运行一次性任务(例如,数据库迁移)。当十二因素应用程序的一次性任务运行时,它应该作为一个独立的进程运行,而不是处理常规请求的进程。但是,一次性进程应该与所有其他进程具有相同的环境。

在 Django 中,这意味着在运行我们的manage.py任务时使用相同的虚拟环境、设置文件和环境变量作为我们的正常进程。这就是我们之前迁移数据库时所做的。

快速审查本节

在审查了十二因素应用程序的所有原则之后,我们将看看 Django 项目如何遵循这些原则,以帮助我们的项目易于部署、扩展和自动化。

Django 项目和严格的十二因素应用程序之间的主要区别在于,Django 应用程序是由 Web 服务器而不是作为独立进程运行的(因素 6)。然而,只要我们避免复杂的 Web 服务器配置(就像在本书中所做的那样),我们就可以继续获得作为十二因素应用程序的好处。

摘要

在本章中,我们专注于将 Django 部署到运行 Apache 和 mod_wsgi 的 Linux 服务器上。我们还审查了十二因素应用程序的原则以及 Django 应用程序如何使用它们来实现易于部署、扩展和自动化。

恭喜!您已经推出了 Answerly。

在下一章中,我们将看看如何创建一个名为 MailApe 的邮件列表管理应用程序。

第十章:启动 Mail Ape

在本章中,我们将开始构建 Mail Ape,一个邮件列表管理器,让用户可以开始邮件列表、注册邮件列表,然后给人发消息。订阅者必须确认他们对邮件列表的订阅,并且能够取消订阅。这将帮助我们确保 Mail Ape 不被用来向用户发送垃圾邮件。

在本章中,我们将构建 Mail Ape 的核心 Django 功能:

  • 我们将构建描述 Mail Ape 的模型,包括MailingListSubscriber

  • 我们将使用 Django 的基于类的视图来创建网页

  • 我们将使用 Django 内置的身份验证功能让用户登录

  • 我们将确保只有MailingList模型实例的所有者才能给其订阅者发送电子邮件

  • 我们将创建模板来生成 HTML 以显示订阅和给用户发送电子邮件的表单

  • 我们将使用 Django 内置的开发服务器在本地运行 Mail Ape

该项目的代码可在github.com/tomaratyn/MailApe上找到。

Django 遵循模型视图模板MVT)模式,以分离模型、控制和表示逻辑,并鼓励可重用性。模型代表我们将在数据库中存储的数据。视图负责处理请求并返回响应。视图不应该包含 HTML。模板负责响应的主体和定义 HTML。这种责任的分离已被证明使编写代码变得容易。

让我们开始创建 Mail Ape 项目。

创建 Mail Ape 项目

在本节中,我们将创建 MailApe 项目:

$ mkdir mailape
$ cd mailape

本书中的所有路径都将相对于此目录。

列出我们的 Python 依赖项

接下来,让我们创建一个requirements.txt文件来跟踪我们的 Python 依赖项:

django<2.1
psycopg2<2.8
django-markdownify==0.3.0
django-crispy-forms==1.7.0

现在我们知道我们的需求,我们可以按照以下方式安装它们:

$ pip install -r requirements.txt

这将安装以下四个库:

  • Django:我们最喜欢的 Web 应用程序框架

  • psycopg2:Python PostgreSQL 库;我们将在生产和开发中都使用 PostgreSQL

  • django-markdownify:一个使在 Django 模板中呈现 markdown 变得容易的库

  • django-crsipy-forms:一个使在模板中创建 Django 表单变得容易的库

有了 Django 安装,我们可以使用django-admin实用程序来创建我们的项目。

创建我们的 Django 项目和应用程序

Django 项目由配置目录和一个或多个 Django 应用程序组成。已安装的应用程序封装了项目的实际功能。默认情况下,配置目录以项目命名。

Web 应用程序通常由远不止执行的 Django 代码组成。我们需要配置文件、系统依赖和文档。为了帮助未来的开发人员(包括我们未来的自己),我们将努力清晰地标记每个目录:

$ django-admin startporject config
$ mv config django
$ tree django
django
├── config
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

通过这种方法,我们的目录结构清楚地指明了我们的 Django 代码和配置的位置。

接下来,让我们创建将封装我们功能的应用程序:

$ python manage.py startapp mailinglist
$ python manage.py startapp user

对于每个应用程序,我们应该创建一个 URLConf。URLConf 确保请求被路由到正确的视图。URLConf 是路径列表,提供路径的视图和路径的名称。URLConfs 的一个很棒的功能是它们可以相互包含。当创建 Django 项目时,它会得到一个根 URLConf(我们的在django/config/urls.py)。由于 URLConf 可能包含其他 URLConfs,名称提供了一种重要的方式来引用 URL 路径到视图,而不需要知道视图的完整 URL 路径。

创建我们应用的 URLConfs

让我们为mailinglist应用程序创建一个 URLConf,位于django/mailinglist/urls.py中:

from django.urls import path

from mailinglist import views

app_name = 'mailinglist'

urlpatterns = [
]

app_name变量用于在名称冲突的情况下限定路径。在解析路径名时,我们可以使用mailinglist:前缀来确保它来自此应用程序。随着我们构建视图,我们将向urlpatterns列表添加path

接下来,让我们通过创建django/user/urls.pyuser应用程序创建另一个 URLConf:

from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path

import user.views

app_name = 'user'
urlpatterns = [
]

太棒了!现在,让我们将它们包含在位于django/config/urls.py中的根 ULRConf 中:

from django.contrib import admin
from django.urls import path, include

import mailinglist.urls
import user.urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('user/', include(user.urls, namespace='user')),
    path('mailinglist/', include(mailinglist.urls, namespace='mailinglist')),
]

根 URLConf 就像我们应用程序的 URLConfs 一样。它有一个path()对象的列表。根 URLConfs 中的path()对象通常没有视图,而是include()其他 URLConfs。让我们来看看这里的两个新函数:

  • path(): 这需要一个字符串和一个视图或include()的结果。Django 将在 URLConf 中迭代path(),直到找到与请求路径匹配的路径。然后 Django 将请求传递给该视图或 URLConf。如果是 URLConf,则会检查path()的列表。

  • include(): 这需要一个 URLConf 和一个命名空间名称。命名空间将 URLConfs 相互隔离,以便我们可以防止名称冲突,确保我们可以区分appA:indexappB:indexinclude()返回一个元组;admin.site.urls上的对象已经是一个正确格式的元组,所以我们不必使用include()。通常,我们总是使用include()

如果 Django 找不到与请求路径匹配的path()对象,那么它将返回 404 响应。

这个 URLConf 的结果如下:

  • 任何以admin/开头的请求将被路由到管理员应用的 URLConf

  • 任何以mailinglist/开头的请求将被路由到mailinglist应用的 URLConf

  • 任何以user/开头的请求将被路由到user应用的 URLConf

安装我们项目的应用程序

让我们更新django/config/settings.py以安装我们的应用程序。我们将更改INSTALLED_APPS设置,如下面的代码片段所示:

INSTALLED_APPS = [
    'user',
    'mailinglist',

    'crispy_forms',
    'markdownify',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

现在我们已经配置好了我们的项目和应用程序,让我们为我们的mailinglist应用创建模型。

创建邮件列表模型

在这一部分,我们将为我们的mailinglist应用创建模型。Django 提供了丰富而强大的 ORM,让我们能够在 Python 中定义我们的模型,而不必直接处理数据库。ORM 将我们的 Django 类、字段和对象转换为关系数据库概念:

  • 模型类映射到关系数据库表

  • 字段映射到关系数据库列

  • 模型实例映射到关系数据库行

每个模型还带有一个默认的管理器,可在objects属性中使用。管理器提供了在模型上运行查询的起点。管理器最重要的方法之一是create()。我们可以使用create()在数据库中创建模型的实例。管理器也是获取模型的QuerySet的起点。

QuerySet代表模型的数据库查询。QuerySet是惰性的,只有在迭代或转换为bool时才执行。QuerySet API 提供了大部分 SQL 的功能,而不与特定的数据库绑定。两个特别有用的方法是QuerySet.filter()QuerySet.exclude()QuerySet.filter()让我们将QuerySet的结果过滤为只匹配提供的条件的结果。QuerySet.exclude()让我们排除不匹配条件的结果。

让我们从第一个模型MailingList开始。

创建邮件列表模型

我们的MailingList模型将代表我们的一个用户创建的邮件列表。这将是我们系统中的一个重要模型,因为许多其他模型将引用它。我们还可以预期MailingListid将需要公开暴露,以便将订阅者关联回来。为了避免让用户枚举 Mail Ape 中的所有邮件列表,我们希望确保我们的MailingList ID 是非顺序的。

让我们将我们的MailingList模型添加到django/mailinglist/models.py中:

import uuid

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

class MailingList(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=140)
    owner = models.ForeignKey(to=settings.AUTH_USER_MODEL,
                              on_delete=models.CASCADE)

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse(
            'mailinglist:manage_mailinglist',
            kwargs={'pk': self.id}
        )

    def user_can_use_mailing_list(self, user):
        return user == self.owner

让我们更仔细地看看我们的MailingList模型:

  • class MailingList(models.Model)::所有 Django 模型都必须继承自Model类。

  • id = models.UUIDField: 这是我们第一次为模型指定id字段。通常,我们让 Django 自动为我们提供一个。在这种情况下,我们想要非顺序的 ID,所以我们使用了一个提供通用唯一标识符UUID)的字段。Django 将在我们生成迁移时创建适当的数据库字段(参考创建数据库迁移部分)。然而,我们必须在 Python 中生成 UUID。为了为每个新模型生成新的 UUID,我们使用了default参数和 Python 的uuid4函数。为了告诉 Django 我们的id字段是主键,我们使用了primary_key参数。我们进一步传递了editable=False以防止对id属性的更改。

  • name = models.CharField: 这将代表邮件列表的名称。CharField将被转换为VARCHAR列,所以我们必须为它提供一个max_length参数。

  • owner = models.ForeignKey: 这是对 Django 用户模型的外键。在我们的情况下,我们将使用默认的django.contrib.auth.models.User类。我们遵循 Django 避免硬编码这个模型的最佳实践。通过引用settings.AUTH_USER_MODEL,我们不会将我们的应用程序与项目过于紧密地耦合。这鼓励未来的重用。on_delete=models.CASCADE参数意味着如果用户被删除,他们的所有MailingList模型实例也将被删除。

  • def __str__(self): 这定义了如何将邮件列表转换为str。当需要打印或显示MailingList时,Django 和 Python 都会使用这个方法。

  • def get_absolute_url(self): 这是 Django 模型上的一个常见方法。get_absolute_url()返回代表模型的 URL 路径。在我们的情况下,我们返回这个邮件列表的管理页面。我们不会硬编码路径。相反,我们使用reverse()在运行时解析路径,提供 URL 的名称。我们将在创建 URLConf部分讨论命名 URL。

  • def user_can_use_mailing_list(self, user): 这是我们为自己方便添加的一个方法。它检查用户是否可以使用(查看相关项目和/或发送消息)到这个邮件列表。Django 的Fat models哲学鼓励将这样的决策代码放在模型中,而不是在视图中。这为我们提供了一个决策的中心位置,确保不要重复自己DRY)。

现在我们有了我们的MailingList模型。接下来,让我们创建一个模型来捕获邮件列表的订阅者。

创建Subscriber模型

在这一部分,我们将创建一个Subscriber模型。Subscriber模型只能属于一个MailingList,并且必须确认他们的订阅。由于我们需要引用订阅者以获取他们的确认和取消订阅页面,我们希望他们的id实例也是非顺序的。

让我们在django/mailinglist/models.py中创建Subscriber模型。

class Subscriber(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    email = models.EmailField()
    confirmed = models.BooleanField(default=False)
    mailing_list = models.ForeignKey(to=MailingList, on_delete=models.CASCADE)

    class Meta:
        unique_together = ['email', 'mailing_list', ]

Subscriber模型与MailingList模型有一些相似之处。基类和UUIDField的功能相同。让我们看看一些不同之处:

  • models.EmailField(): 这是一个专门的CharField,但会进行额外的验证,以确保值是一个有效的电子邮件地址。

  • models.BooleanField(default=False): 这让我们存储True/False值。我们需要使用这个来跟踪用户是否真的打算订阅邮件列表。

  • models.ForeignKey(to=MailingList...): 这让我们在SubscriberMailingList模型实例之间创建一个外键。

  • unique_together: 这是SubscriberMeta内部类的一个属性。Meta内部类让我们可以在表上指定信息。例如,unique_together让我们在表上添加额外的唯一约束。在这种情况下,我们防止用户使用相同的电子邮件地址注册两次。

现在我们可以跟踪Subscriber模型实例了,让我们跟踪用户想要发送到他们的MailingList的消息。

创建Message模型

我们的用户将希望向他们的MailingListSubscriber模型实例发送消息。为了知道要发送给这些订阅者什么,我们需要将消息存储为 Django 模型。

Message应该属于MailingList并具有非连续的id。我们需要保存这些消息的主题和正文。我们还希望跟踪发送开始和完成的时间。

让我们将Message模型添加到django/mailinglist/models.py中:

class Message(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    mailing_list = models.ForeignKey(to=MailingList, on_delete=models.CASCADE)
    subject = models.CharField(max_length=140)
    body = models.TextField()
    started = models.DateTimeField(default=None, null=True)
    finished = models.DateTimeField(default=None, null=True)

再次,Message模型在其基类和字段上与我们之前的模型非常相似。我们在这个模型中看到了一些新字段。让我们更仔细地看看这些新字段:

  • models.TextField(): 用于存储任意长的字符数据。所有主要数据库都有TEXT列类型。这对于存储用户的Messagebody属性非常有用。

  • models.DateTimeField(default=None, null=True): 用于存储日期和时间值。在 Postgres 中,这将成为TIMESTAMP列。null参数告诉 Django 该列应该能够接受NULL值。默认情况下,所有字段都对它们有一个NOT NULL约束。

我们现在有了我们的模型。让我们使用数据库迁移在我们的数据库中创建它们。

使用数据库迁移

数据库迁移描述了如何将数据库转换为特定状态。在本节中,我们将做以下事情:

  • 为我们的mailinglist应用程序模型创建数据库迁移

  • 在 Postgres 数据库上运行迁移

当我们对模型进行更改时,我们可以让 Django 生成用于创建这些表、字段和约束的代码。Django 生成的迁移是使用 Django 开发人员也可以使用的 API 创建的。如果我们需要进行复杂的迁移,我们可以自己编写迁移。请记住,正确的迁移包括应用和撤消迁移的代码。如果出现问题,我们希望有一种方法来撤消我们的迁移。当 Django 生成迁移时,它总是为我们生成两个迁移。

让我们首先配置 Django 连接到我们的 PostgreSQL 数据库。

配置数据库

要配置 Django 连接到我们的 Postgres 数据库,我们需要更新django/config/settings.py中的DATABASES设置:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'mailape',
        'USER': 'mailape',
        'PASSWORD': 'development',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

您不应该在settings.py文件中将密码硬编码到生产数据库中。如果您连接到共享或在线实例,请使用环境变量设置用户名、密码和主机,并使用os.getenv()访问它们,就像我们在之前的生产部署章节中所做的那样(第五章,“使用 Docker 部署”和第九章,部署 Answerly)。

Django 不能自行创建数据库和用户。我们必须自己做。您可以在本章的代码中找到执行此操作的脚本。

接下来,让我们为模型创建迁移。

创建数据库迁移

要创建我们的数据库迁移,我们将使用 Django 放在 Django 项目顶部的manage.py脚本(django/manage.py):

$ cd django
$ python manage.py makemigrations
Migrations for 'mailinglist':
  mailinglist/migrations/0001_initial.py
    - Create model MailingList
    - Create model Message
    - Create model Subscriber
    - Alter unique_together for subscriber (1 constraint(s))

太棒了!现在我们有了迁移,我们可以在我们的本地开发数据库上运行它们。

运行数据库迁移

我们使用manage.py将我们的数据库迁移应用到正在运行的数据库。在命令行上执行以下操作:

$ cd django
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, mailinglist, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying mailinglist.0001_initial... OK
  Applying sessions.0001_initial... OK

当我们运行manage.py migrate而不提供应用程序时,它将在所有安装的 Django 应用程序上运行所有迁移。我们的数据库现在具有mailinglist应用程序模型和auth应用程序模型(包括User模型)的表。

现在我们有了我们的模型和数据库设置,让我们确保我们可以使用 Django 的表单 API 验证这些模型的用户输入。

邮件列表表单

开发人员必须解决的一个常见问题是如何验证用户输入。Django 通过其表单 API 提供输入验证。表单 API 可用于使用与模型 API 非常相似的 API 描述 HTML 表单。如果我们想创建描述 Django 模型的表单,那么 Django 表单的ModelForm为我们提供了一种快捷方式。我们只需要描述我们从默认表单表示中更改的内容。

当实例化 Django 表单时,可以提供以下三个参数中的任何一个:

  • data:最终用户请求的原始输入

  • initial:我们可以为表单设置的已知安全初始值

  • instance:表单描述的实例,仅在ModelForm

如果表单提供了data,那么它被称为绑定表单。绑定表单可以通过调用is_valid()来验证它们的data。经过验证的表单的安全数据可以在cleaned_data字典下使用(以字段名称为键)。错误可以通过errors属性获得,它返回一个字典。绑定的ModelForm也可以使用save()方法创建或更新其模型实例。

即使没有提供任何参数,表单仍然能够以 HTML 形式打印自己,使我们的模板更简单。这种机制帮助我们实现了“愚蠢模板”的目标。

让我们通过创建SubscriberForm类来开始创建我们的表单。

创建订阅者表单

Mail Ape 必须执行的一个重要任务是接受新的Subscriber的邮件,用于MailingList。让我们创建一个表单来进行验证。

SubscriberForm必须能够验证输入是否为有效的电子邮件。我们还希望它保存我们的新Subscriber模型实例并将其与适当的MailingList模型实例关联起来。

让我们在django/mailinglist/forms.py中创建该表单:

from django import forms

from mailinglist.models import MailingList, Subscriber

class SubscriberForm(forms.ModelForm):
    mailing_list = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=MailingList.objects.all(),
        disabled=True,
    )

    class Meta:
        model = Subscriber
        fields = ['mailing_list', 'email', ]

让我们仔细看看我们的SubscriberForm

  • class SubscriberForm(forms.ModelForm)::这表明我们的表单是从ModelForm派生的。ModelForm知道要检查我们的内部Meta类,以获取关于可以用作此表单基础的模型和字段的信息。

  • mailing_list = forms.ModelChoiceField:这告诉我们的表单使用我们自定义配置的ModelChoiceField,而不是表单 API 默认使用的。默认情况下,Django 将显示一个ModelChoiceField,它将呈现为下拉框。用户可以使用下拉框选择相关的模型。在我们的情况下,我们不希望用户能够做出选择。当我们显示一个渲染的SubscriberForm时,我们希望它配置为特定的邮件列表。为此,我们将widget参数更改为HiddenInput类,并将字段标记为disabled。我们的表单需要知道对于该表单有效的MailingList模型实例。我们提供一个匹配所有MailingList模型实例的QuerySet对象。

  • model = Subscriber:这告诉表单的Meta内部类,这个表单是基于Subscriber模型的。

  • fields = ['mailing_list', 'email', ]:这告诉表单只包括模型中的以下字段。

接下来,让我们创建一个表单,用于捕获我们的用户想要发送到他们的MailingListMessage

创建消息表单

我们的用户将希望向他们的MailingList发送Message。我们将提供一个网页,用户可以在其中创建这些消息的表单。在我们创建页面之前,让我们先创建表单。

让我们将我们的MessageForm类添加到django/mailinglist/forms.py中:

from django import forms

from mailinglist.models import MailingList, Message

class MessageForm(forms.ModelForm):
    mailing_list = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=MailingList.objects.all(),
        disabled=True,
    )

    class Meta:
        model = Message
        fields = ['mailing_list', 'subject', 'body', ]

正如您在前面的代码中所注意到的,MessageForm的工作方式与SubscriberFrom相同。唯一的区别是我们在Meta内部类中列出了不同的模型和不同的字段。

接下来,让我们创建MailingListForm类,我们将用它来接受邮件列表的名称的输入。

创建邮件列表表单

现在,我们将创建一个MailingListForm,它将接受邮件列表的名称和所有者。我们将在owner字段上使用与之前相同的HiddenInputdisabled字段模式。我们希望确保用户无法更改邮件列表的所有者。

让我们将我们的表单添加到django/mailinglist/forms.py中:

from django import forms
from django.contrib.auth import get_user_model

from mailinglist.models import MailingList

class MailingListForm(forms.ModelForm):
    owner = forms.ModelChoiceField(
        widget=forms.HiddenInput,
        queryset=get_user_model().objects.all(),
        disabled=True,
    )

    class Meta:
        model = MailingList
        fields = ['owner', 'name']

MailingListForm与我们之前的表单非常相似,但引入了一个新的函数get_user_model()。我们需要使用get_user_model(),因为我们不想将自己与特定的用户模型耦合在一起,但我们需要访问该模型的管理器以获取QuerySet

现在我们有了我们的表单,我们可以为我们的mailinglist Django 应用程序创建视图。

创建邮件列表视图和模板

在前面的部分中,我们创建了可以用来收集和验证用户输入的表单。在本节中,我们将创建实际与用户通信的视图和模板。模板定义了文档的 HTML。

基本上,Django 视图是一个接受请求并返回响应的函数。虽然我们在本书中不会使用这些基于函数的视图FBVs),但重要的是要记住,一个视图只需要满足这两个责任。如果处理视图还导致其他操作发生(例如,发送电子邮件),那么我们应该将该代码放在服务模块中,而不是直接放在视图中。

Web 开发人员面临的许多工作是重复的(例如,处理表单,显示特定模型,列出该模型的所有实例等)。Django 的“电池包含”哲学意味着它包含了工具,使这些重复的任务更容易。

Django 通过提供丰富的基于类的视图CBVs)使常见的 Web 开发人员任务更容易。CBVs 使用面向对象编程OOP)的原则来增加代码重用。Django 提供了丰富的 CBV 套件,使处理表单或为模型实例显示 HTML 页面变得容易。

HTML 视图返回的内容来自于渲染模板。Django 中的模板通常是用 Django 的模板语言编写的。Django 也可以支持其他模板语言(例如 Jinja)。通常,每个视图都与一个模板相关联。

让我们首先创建许多视图将需要的一些资源。

常见资源

在这一部分,我们将创建一些我们的视图和模板将需要的常见资源:

  • 我们将创建一个基础模板,所有其他模板都可以扩展。在所有页面上使用相同的基础模板将给 Mail Ape 一个统一的外观和感觉。

  • 我们将创建一个MailingListOwnerMixin类,它将让我们保护邮件列表消息免受未经授权的访问。

让我们从创建一个基础模板开始。

创建基础模板

让我们为 Mail Ape 创建一个基础模板。这个模板将被我们所有的页面使用,以给我们整个 Web 应用程序一个一致的外观。

Django 模板语言DTL)让我们编写 HTML(或其他基于文本的格式),并让我们使用标签变量过滤器来执行代码以定制 HTML。让我们更仔细地看看这三个概念:

  • 标签:它们被{% %}包围,可能({% block body%}{% endblock %})或可能不({% url "myurl" %})包含一个主体。

  • variables:它们被{{ }}包围,并且必须在模板的上下文中设置(例如,{{ mailinglist }})。尽管 DTL 变量类似于 Python 变量,但也有区别。最关键的两个区别在于可执行文件和字典。首先,DTL 没有语法来传递参数给可执行文件(你永远不必使用{{foo(1)}})。如果你引用一个变量并且它是可调用的(例如,一个函数),那么 Django 模板语言将调用它并返回结果(例如,{{mailinglist.get_absolute_url}})。其次,DTL 不区分对象属性、列表中的项目和字典中的项目。所有这三个都使用点来访问:{{mailinglist.name}}{{mylist.1}}{{mydict.mykey}}

  • filters:它们跟随一个变量并修改其值(例如,{{ mailinglist.name | upper}}将以大写形式返回邮件列表的名称)。

我们将在继续创建 Mail Ape 时查看这三个示例。

让我们创建一个公共模板目录—django/templates—并将我们的模板放在django/templates/base.html中:

<!DOCTYPE html>
<html lang="en" >
<head >
  <meta charset="UTF-8" >
  <title >{% block title %}{% endblock %}</title >
  <link rel="stylesheet"
        href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.3/css/bootstrap.min.css"
  />
</head >
<body >
<div class="container" >
  <nav class="navbar navbar-light bg-light" >
    <a class="navbar-brand" href="#" >Mail Ape </a >
    <ul class="navbar-nav" >
      <li class="nav-item" >
        <a class="nav-link"
           href="{% url "mailinglist:mailinglist_list" %}" >
          Your Mailing Lists
        </a >
      </li >
      {% if request.user.is_authenticated %}
        <li class="nav-item" >
          <a class="nav-link"
             href="{% url "user:logout" %}" >
            Logout
          </a >
        </li >
      {% else %}
        <li class="nav-item" >
          <a class="nav-link"
             href="{% url "user:login" %}" >
            Your Mailing Lists
          </a >
        </li >
        <li class="nav-item" >
          <a class="nav-link"
             href="{% url "user:register" %}" >
            Your Mailing Lists
          </a >
        </li >
      {% endif %}
    </ul >
  </nav >
  {% block body %}
  {% endblock %}
</div >
</body >
</html >

在我们的基本模板中,我们将注意以下三个标签的示例:

  • {% url ... %}:这返回到视图的路径。这与我们之前看到的reverse()函数在 Django 模板中的工作方式相同。

  • {% if ... %} ... {% else %} ... {% endif %}:这与 Python 开发人员期望的工作方式相同。{% else %}子句是可选的。Django 模板语言还支持{% elif ... %},如果我们需要在多个选择中进行选择。

  • {% block ... %}:这定义了一个块,一个扩展base.html的模板可以用自己的内容替换。我们有两个块,bodytitle

我们现在有一个基本模板,我们的其他模板可以通过提供 body 和 title 块来使用。

既然我们有了模板,我们必须告诉 Django 在哪里找到它。让我们更新django/config/settings.py,让 Django 知道我们的新django/templates目录。

django/config/settings.py中,找到以Templates开头的行。我们需要将我们的templates目录添加到DIRS键下的列表中:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates'),
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            # do not change OPTIONS, omitted for brevity
        },
    },
]

Django 让我们通过在运行时计算BASE_DIR来避免将路径硬编码到django/templates中。这样,我们可以在不同的环境中使用相同的设置。

我们刚刚看到的另一个重要设置是APP_DIRS。这个设置告诉 Django 在查找模板时检查每个安装的应用程序的templates目录。这意味着我们不必为每个安装的应用程序更新DIRS键,并且让我们将模板隔离在我们的应用程序下(增加可重用性)。最后,重要的是要记住应用程序按照它们在INSTALLED_APPS中出现的顺序进行搜索。如果有模板名称冲突(例如,两个应用程序提供名为registration/login.html的模板),那么将使用INSTALLED_APPS中列出的第一个。

接下来,让我们配置我们的项目在呈现 HTML 表单时使用 Bootstrap 4。

配置 Django Crispy Forms 以使用 Bootstrap 4

在我们的基本模板中,我们包含了 Bootstrap 4 的 css 模板。为了方便使用 Bootstrap 4 呈现表单并为其设置样式,我们将使用一个名为 Django Crispy Forms 的第三方 Django 应用程序。但是,我们必须配置 Django Crispy Forms 以告诉它使用 Bootstrap 4。

让我们在django/config/settings.py的底部添加一个新的设置:

CRISPY_TEMPLATE_PACK = 'bootstrap4'

现在,Django Crispy Forms 配置为在呈现表单时使用 Bootstrap 4。我们将在本章后面的部分中查看它,在涵盖在模板中呈现表单的部分。

接下来,让我们创建一个 mixin,确保只有邮件列表的所有者才能影响它们。

创建一个 mixin 来检查用户是否可以使用邮件列表

Django 使用基于类的视图CBVs)使代码重用更容易,简化重复的任务。在mailinglist应用程序中,我们将不得不做的重复任务之一是保护MailingList及其相关模型,以免被其他用户篡改。我们将创建一个 mixin 来提供保护。

mixin 是一个提供有限功能的类,旨在与其他类一起使用。我们之前见过LoginRequired mixin,它可以与视图类一起使用,以保护视图免受未经身份验证的访问。在本节中,我们将创建一个新的 mixin。

让我们在django/mailinglist/mixins.py中创建我们的UserCanUseMailingList mixin:

from django.core.exceptions import PermissionDenied, FieldDoesNotExist

from mailinglist.models import MailingList

class UserCanUseMailingList:

    def get_object(self, queryset=None):
        obj = super().get_object(queryset)
        user = self.request.user
        if isinstance(obj, MailingList):
            if obj.user_can_use_mailing_list(user):
                return obj
            else:
                raise PermissionDenied()

        mailing_list_attr = getattr(obj, 'mailing_list')
        if isinstance(mailing_list_attr, MailingList):
            if mailing_list_attr.user_can_use_mailing_list(user):
                return obj
            else:
                raise PermissionDenied()
        raise FieldDoesNotExist('view does not know how to get mailing '
                                   'list.')

我们的类定义了一个方法,get_object(self, queryset=None)。这个方法与SingleObjectMixin.get_object()具有相同的签名,许多 Django 内置的 CBV(例如DetailView)使用它。我们的get_object()实现不做任何工作来检索对象。相反,我们的get_object只是检查父对象检索到的对象,以检查它是否是或者拥有MailingList,并确认已登录的用户可以使用邮件列表。

mixin 的一个令人惊讶的地方是它依赖于一个超类,但不继承自一个。在get_object()中,我们明确调用super(),但UserCanUseMailingList没有任何基类。mixin 类不希望单独使用。相反,它们将被类使用,这些类子类化它们一个或多个其他类。

我们将在接下来的几节中看看这是如何工作的。

创建 MailingList 视图和模板

现在,让我们来看看将处理用户请求并返回从我们的模板创建的 UI 的响应的视图。

让我们首先创建一个列出所有我们的MailingList的视图。

创建 MailingListListView 视图

我们将创建一个视图,显示用户拥有的邮件列表。

让我们在django/mailinglist/views.py中创建我们的MailingListListView

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView

from mailinglist.models import  MailingList

class MailingListListView(LoginRequiredMixin, ListView):

    def get_queryset(self):
        return MailingList.objects.filter(owner=self.request.user)

我们的观点源自两个视图,LoginRequiredMixinListViewLoginRequiredMixin是一个 mixin,确保未经身份验证的用户发出的请求被重定向到登录视图,而不是被处理。为了帮助ListView知道列出什么,我们将重写get_queryset()方法,并返回一个包含当前登录用户拥有的MailingListQuerySet。为了显示结果,ListView将尝试在appname/modelname_list.html渲染模板。在我们的情况下,ListView将尝试渲染mailinglist/mailinglist_list.html

让我们在django/mailinglist/templates/mailinglist/mailinglist_list.html中创建该模板:

{% extends "base.html" %}

{% block title %}
  Your Mailing Lists
{% endblock %}

{% block body %}
  <div class="row user-mailing-lists" >
    <div class="col-sm-12" >
      <h1 >Your Mailing Lists</h1 >
      <div >
        <a class="btn btn-primary"
           href="{% url "mailinglist:create_mailinglist" %}" >New List</a >
      </div >
      <p > Your mailing lists:</p >
      <ul class="mailing-list-list">
        {% for mailinglist in mailinglist_list %}
          <li class="mailinglist-item">
            <a href="{% url "mailinglist:manage_mailinglist" pk=mailinglist.id %}" >
              {{ mailinglist.name }}
            </a >
          </li >
        {% endfor %}
      </ul >
    </div >
  </div >
{% endblock %}

我们的模板扩展了base.html。当一个模板扩展另一个模板时,它只能将 HTML 放入先前定义的block中。我们还将看到许多新的 Django 模板标签。让我们仔细看看它们:

  • {% extends "base.html" %}:这告诉 Django 模板语言我们正在扩展哪个模板。

  • {% block title %}… {% endblock %}:这告诉 Django 我们正在提供新的代码,它应该放在扩展模板的title块中。该块中的先前代码(如果有)将被替换。

  • {% for mailinglist in mailinglist_list %} ... {% endfor %}:这为列表中的每个项目提供了一个循环。

  • {% url … %}url标签将为命名的path生成 URL 路径。

  • {% url ... pk=...%}:这与前面的点一样工作,但在某些情况下,path可能需要参数(例如要显示的MailingList的主键)。我们可以在url标签中指定这些额外的参数。

现在我们有一个可以一起使用的视图和模板。

任何视图的最后一步都是将应用的 URLConf 添加到其中。让我们更新django/mailinglist/urls.py

from django.urls import path

from mailinglist import views

app_name = 'mailinglist'

urlpatterns = [
    path('',
         views.MailingListListView.as_view(),
         name='mailinglist_list'),
]

考虑到我们之前如何配置了根 URLConf,任何发送到/mailinglist/的请求都将被路由到我们的MailingListListView

接下来,让我们添加一个视图来创建新的MailingList

创建 CreateMailingListView 和模板

我们将创建一个视图来创建邮件列表。当我们的视图接收到GET请求时,视图将向用户显示一个表单,用于输入邮件列表的名称。当我们的视图接收到POST请求时,视图将验证表单,要么重新显示带有错误的表单,要么创建邮件列表并将用户重定向到列表的管理页面。

现在让我们在django/mailinglist/views.py中创建视图:

class CreateMailingListView(LoginRequiredMixin, CreateView):
    form_class = MailingListForm
    template_name = 'mailinglist/mailinglist_form.html'

    def get_initial(self):
        return {
            'owner': self.request.user.id,
        }

CreateMailingListView派生自两个类:

  • LoginRequiredMixin会重定向未与已登录用户关联的请求,使其无法被处理(我们将在本章后面的创建用户应用部分进行配置)

  • CreateView知道如何处理form_class中指定的表单,并使用template_name中列出的模板进行渲染

CreateView是在不需要提供几乎任何额外信息的情况下完成大部分工作的类。处理表单,验证它,并保存它总是相同的,而CreateView有代码来执行这些操作。如果我们需要更改某些行为,我们可以重写CreateView提供的钩子之一,就像我们在get_initial()中所做的那样。

CreateView实例化我们的MailingListForm时,CreateView调用其get_initial()方法来获取表单的initial数据(如果有的话)。我们使用这个钩子来确保表单的所有者设置为已登录用户的id。请记住,MailingListFormowner字段已被禁用,因此表单将忽略用户提供的任何数据。

接下来,让我们在django/mailinglist/templates/mailinglist/mailinglist_form.html中创建我们的CreateView的模板:

{% extends "base.html" %}

{% load crispy_forms_tags %}

{% block title %}
  Create Mailing List
{% endblock %}

{% block body %}
  <h1 >Create Mailing List</h1 >
  <form method="post" class="col-sm-4" >
    {% csrf_token %}
    {{ form | crispy }}
    <button class="btn btn-primary" type="submit" >Submit</button >
  </form >
{% endblock %}

我们的模板扩展了base.html。当一个模板扩展另一个模板时,它只能在已被扩展模板定义的块中放置 HTML。我们还使用了许多新的 Django 模板标签。让我们仔细看看它们:

  • {% load crispy_forms_tags %}:这告诉 Django 加载一个新的模板标签库。在这种情况下,我们将加载我们安装的 Django Crispy Forms 应用的crispy_from_tags。这为我们提供了稍后在本节中将看到的crispy过滤器。

  • {% csrf_token %}:Django 处理的任何表单都必须具有有效的 CSRF 令牌,以防止 CSRF 攻击(参见第三章,海报、头像和安全)。csrf_token标签返回一个带有正确 CSRF 令牌的隐藏输入标签。请记住,通常情况下,Django 不会处理没有 CSRF 令牌的 POST 请求。

  • {{ form | crispy }}form变量是我们的视图正在处理的表单实例的引用,并且通过我们的CreateView将其传递到这个模板的上下文中。crispy是由crispy_form_tags标签库提供的过滤器,将使用 HTML 标签和 Bootstrap 4 中使用的 CSS 类输出表单。

我们现在有一个视图和模板可以一起使用。视图能够使用模板创建用户界面以输入表单中的数据。然后视图能够处理表单的数据并从有效的表单数据创建MailingList模型,或者如果数据有问题,则重新显示表单。Django Crispy Forms 库使用 Bootstrap 4 CSS 框架的 HTML 和 CSS 渲染表单。

最后,让我们将我们的视图添加到mailinglist应用的 URLConf 中。在django/mailinglist/urls.py中,让我们向 URLConf 添加一个新的path()对象:

    path('new',
         views.CreateMailingListView.as_view(),
         name='create_mailinglist')

考虑到我们之前如何配置了根 URLConf,任何发送到/mailinglist/new的请求都将被路由到我们的CreatingMailingListView

接下来,让我们创建一个视图来删除MailingList

创建 DeleteMailingListView 视图

用户在MailingList不再有用后会想要删除它们。让我们创建一个视图,在GET请求上提示用户进行确认,并在POST上删除MailingList

我们将把我们的视图添加到django/mailinglist/views.py中:

class DeleteMailingListView(LoginRequiredMixin, UserCanUseMailingList,
                            DeleteView):
    model = MailingList
    success_url = reverse_lazy('mailinglist:mailinglist_list')

让我们仔细看看DeleteMailingListView从中派生的类:

  • LoginRequiredMixin:这与前面的代码具有相同的功能,确保未经身份验证的用户的请求不被处理。用户只是被重定向到登录页面。

  • UserCanUseMailingList:这是我们在前面的代码中创建的 mixin。DeleteView使用get_object()方法来检索要删除的模型实例。通过将UserCanUseMailingList混合到DeleteMailingListView类中,我们保护了每个用户的MailingList不被未经授权的用户删除。

  • DeleteView:这是一个 Django 视图,它知道如何在GET请求上呈现确认模板,并在POST上删除相关的模型。

为了使 Django 的DeleteView正常工作,我们需要正确配置它。DeleteView知道从其model属性中删除哪个模型。当我们路由请求到它时,DeleteView要求我们提供一个pk参数。为了呈现确认模板,DeleteView将尝试使用appname/modelname_confirm_delete.html。在DeleteMailingListView的情况下,模板将是mailinglist/mailinglist_confirm_delete.html。如果成功删除模型,那么DeleteView将重定向到success_url值。我们避免了硬编码success_url,而是使用reverse_lazy()来引用名称的 URL。reverse_lazy()函数返回一个值,直到用它来创建一个Response对象时才会解析。

让我们创建DeleteMailingListViewdjango/mailinglist/templates/mailinglist/mailinglist_confirm_delete.html中需要的模板:

{% extends "base.html" %}

{% block title %}
  Confirm delete {{ mailinglist.name }}
{% endblock %}

{% block body %}
  <h1 >Confirm Delete?</h1 >
  <form action="" method="post" >
    {% csrf_token %}
    <p >Are you sure you want to delete {{ mailinglist.name }}?</p >
    <input type="submit" value="Yes" class="btn btn-danger btn-sm ">
    <a class="btn btn-primary btn-lg" href="{% url "mailinglist:manage_mailinglist" pk=mailinglist.id %}">No</a>
  </form >
{% endblock %}

在这个模板中,我们不使用任何表单,因为没有任何输入需要验证。表单提交本身就是确认。

最后一步将是将我们的视图添加到django/mailinglist/urls.py中的urlpatterns列表中:

 path('<uuid:pk>/delete',
     views.DeleteMailingListView.as_view(),
     name='delete_mailinglist'),

这个path看起来不同于我们之前见过的path()调用。在这个path中,我们包含了一个命名参数,它将被解析出路径并传递给视图。我们使用<converter:name>格式来指定path命名参数。转换器知道如何匹配路径的一部分(例如,uuid转换器知道如何匹配 UUID;int知道如何匹配数字;str将匹配除了/之外的任何非空字符串)。然后匹配的文本将作为关键字参数传递给视图,并提供名称。在我们的情况下,要将请求路由到DeleteMailingListView,它必须有这样的路径:/mailinglist/bce93fec-f9c6-4ea7-b1aa-348d3bed4257/delete

现在我们可以列出、创建和删除MailingList,让我们创建一个视图来管理其SubscriberMessage

创建 MailingListDetailView

让我们创建一个视图,列出与MailingList相关的所有SubscriberMessage。我们还需要一个地方来向用户显示MailingList的订阅页面链接。Django 可以很容易地创建一个表示模型实例的视图。

让我们在django/mailinglist/views.py中创建我们的MailingListDetailView

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView

from mailinglist.mixins import UserCanUseMailingList
from mailinglist.models import MailingList

class MailingListDetailView(LoginRequiredMixin, UserCanUseMailingList,
                            DetailView):
    model = MailingList

我们以与之前相同的方式使用LoginRequiredMixinUserCanUseMailingList,并且目的也是相同的。这次,我们将它们与DetailView一起使用,这是最简单的视图之一。它只是为其配置的模型实例呈现模板。它通过从path接收pk参数来检索模型实例,就像DeleteView一样。此外,我们不必显式配置它将使用的模板,因为按照惯例,它使用appname/modelname_detail.html。在我们的情况下,它将是mailinglist/mailinglist_detail.html

让我们在django/mailinglist/templates/mailinglist/mailinglist_detail.html中创建我们的模板:

{% extends "base.html" %}

{% block title %}
  {{ mailinglist.name }} Management
{% endblock %}

{% block body %}
  <h1 >{{ mailinglist.name }} Management
    <a class="btn btn-danger"
       href="{% url "mailinglist:delete_mailinglist" pk=mailinglist.id %}" >
      Delete</a >
  </h1 >

  <div >
    <a href="{% url "mailinglist:create_subscriber" mailinglist_pk=mailinglist.id %}" >Subscription
      Link</a >

  </div >

  <h2 >Messages</h2 >
  <div > Send new
    <a class="btn btn-primary"
       href="{% url "mailinglist:create_message" mailinglist_pk=mailinglist.id %}">
      Send new Message</a >
  </div >
  <ul >
    {% for message in mailinglist.message_set.all %}
      <li >
        <a href="{% url "mailinglist:view_message" pk=message.id %}" >{{ message.subject }}</a >
      </li >
    {% endfor %}
  </ul >

  <h2 >Subscribers</h2 >
  <ul >
    {% for subscriber in mailinglist.subscriber_set.all %}
      <li >
        {{ subscriber.email }}
        {{ subscriber.confirmed|yesno:"confirmed,unconfirmed" }}
        <a href="{% url "mailinglist:unsubscribe" pk=subscriber.id %}" >
          Unsubscribe
        </a >
      </li >
    {% endfor %}
  </ul >
{% endblock %}

上述代码模板只介绍了一个新项目(yesno过滤器),但确实展示了 Django 模板语言的所有工具是如何结合在一起的。

yesno过滤器接受一个值,如果该值评估为True,则返回yes,如果评估为False,则返回no,如果为None,则返回maybe。在我们的情况下,我们传递了一个参数,告诉yesno如果为True则返回confirmed,如果为False则返回unconfirmed

MailingListDetailView类和模板说明了 Django 如何简洁地完成常见的 Web 开发人员任务:显示数据库中行的页面。

接下来,让我们在mailinglist的 URLConf 中为我们的视图创建一个新的path()对象:

    path('<uuid:pk>/manage',
         views.MailingListDetailView.as_view(),
         name='manage_mailinglist')

接下来,让我们为我们的Subscriber模型实例创建视图。

创建 Subscriber 视图和模板

在本节中,我们将创建视图和模板,让用户与我们的Subscriber模型进行交互。这些视图与MailingListMessage视图的主要区别之一是,它们不需要任何混合,因为它们将被公开。它们免受篡改的主要保护是Subscriber由 UUID 标识,具有大的密钥空间,这意味着篡改是不太可能的。

让我们从SubscribeToMailingListView开始。

创建 SubscribeToMailingListView 和模板

我们需要一个视图来收集SubscriberMailingList。让我们在django/mailinglist/views.py中创建一个SubscribeToMailingListView类。

class SubscribeToMailingListView(CreateView):
    form_class = SubscriberForm
    template_name = 'mailinglist/subscriber_form.html'

    def get_initial(self):
        return {
            'mailing_list': self.kwargs['mailinglist_id']
        }

    def get_success_url(self):
        return reverse('mailinglist:subscriber_thankyou', kwargs={
            'pk': self.object.mailing_list.id,
        })

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        mailing_list_id = self.kwargs['mailinglist_id']
        ctx['mailing_list'] = get_object_or_404(
            MailingList,
            id=mailing_list_id)
        return ctx

我们的SubscribeToMailingListView类似于CreateMailingListView,但覆盖了一些新方法:

  • get_success_url(): 这是由CreateView调用的,用于获取重定向用户到已创建模型的 URL。在CreateMailingListView中,我们不需要覆盖它,因为默认行为使用模型的get_absolute_url。我们使用reverse()函数解析路径到感谢页面。

  • get_context_data(): 这让我们向模板的上下文中添加新变量。在这种情况下,我们需要访问用户可能订阅的MailingList以显示MailingList的名称。我们使用 Django 的get_object_or_404()快捷函数通过其 ID 检索MailingList或引发 404 异常。我们将这个视图的path从我们请求的路径中解析出mailinglist_id(参见本节末尾的内容)。

接下来,让我们在mailinglist/templates/mailinglist/subscriber_form.html中创建我们的模板:

{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}
Subscribe to {{ mailing_list }}
{% endblock %}

{% block body %}
<h1>Subscribe to {{ mailing_list }}</h1>
<form method="post" class="col-sm-6 ">
  {% csrf_token %}
  {{ form | crispy }}
  <button class="btn btn-primary" type="submit">Submit</button>
</form>
{% endblock %}

这个模板没有引入任何标签,但展示了另一个例子,说明我们如何使用 Django 的模板语言和 Django Crispy Forms API 快速构建漂亮的 HTML 表单。我们像以前一样扩展base.html,以使我们的页面具有一致的外观和感觉。base.html还提供了我们要放入内容的块。在任何块之外,我们使用{% load %}加载 Django Crispy Forms 标签库,以便我们可以在我们的表单上使用crispy过滤器来生成兼容 Bootstrap 4 的 HTML。

接下来,让我们确保 Django 知道如何将请求路由到我们的新视图,通过向mailinglist应用的 URLConf 的urlpatterns列表添加一个path()

    path('<uuid:mailinglist_id>/subscribe',
         views.SubscribeToMailingListView.as_view(),
         name='subscribe'),

在这个path()中,我们需要匹配我们作为mailinglist_pk传递给视图的uuid参数。这是我们的get_context_data()方法引用的关键字参数。

接下来,让我们创建一个感谢页面,感谢用户订阅邮件列表。

创建感谢订阅视图

用户订阅邮件列表后,我们希望向他们显示一个感谢页面。这个页面对于订阅相同邮件列表的所有用户来说是相同的,因为它将显示邮件列表的名称(而不是订阅者的电子邮件)。为了创建这个视图,我们将使用之前看到的DetailView,但这次没有额外的混合(这里没有需要保护的信息)。

让我们在django/mailinglist/views.py中创建我们的ThankYouForSubscribingView

from django.views.generic import DetailView

from mailinglist.models import  MailingList

class ThankYouForSubscribingView(DetailView):
    model = MailingList
    template_name = 'mailinglist/subscription_thankyou.html'

Django 在DetailView中为我们完成所有工作,只要我们提供model属性。DetailView知道如何查找模型,然后为该模型呈现模板。我们还提供了template_name属性,因为mailinglist/mailinglist_detail.html模板(DetailView默认使用的)已经被MailingListDetailView使用。

让我们在django/mailinglist/templates/mailinglist/subscription_thankyou.html中创建我们的模板:

{% extends "base.html" %}

{% block title %}
  Thank you for subscribing to {{ mailinglist }}
{% endblock %}

{% block body %}
  <div class="col-sm-12" ><h1 >Thank you for subscribing
    to {{ mailinglist }}</h1 >
    <p >Check your email for a confirmation email.</p >
  </div >
{% endblock %}

我们的模板只是显示一个感谢和模板名称。

最后,让我们在mailinglist应用的 URLConf 的urlpatterns列表中添加一个path()ThankYouForSubscribingView

    path('<uuid:pk>/thankyou',
         views.ThankYouForSubscribingView.as_view(),
         name='subscriber_thankyou'),

我们的path需要匹配 UUID,以便将请求路由到ThankYouForSubscribingView。UUID 将作为关键字参数pk传递到视图中。这个pk将被DetailView用来找到正确的MailingList

接下来,我们需要让用户确认他们是否要在这个地址接收电子邮件。

创建订阅确认视图

为了防止垃圾邮件发送者滥用我们的服务,我们需要向我们的订阅者发送一封电子邮件,确认他们确实想要订阅我们用户的邮件列表之一。我们将涵盖发送这些电子邮件,但现在我们将创建确认页面。

这个确认页面的行为会有点奇怪。简单地访问页面将会将Subscriber.confirmed修改为True。这是邮件列表确认页面的标准行为(我们希望避免为我们的订阅者创建额外的工作),但根据 HTTP 规范来说有点奇怪,因为GET请求不应该修改资源。

让我们在django/mailinglist/views.py中创建我们的ConfirmSubscriptionView

from django.views.generic import DetailView

from mailinglist.models import  Subscriber

class ConfirmSubscriptionView(DetailView):
    model = Subscriber
    template_name = 'mailinglist/confirm_subscription.html'

    def get_object(self, queryset=None):
        subscriber = super().get_object(queryset=queryset)
        subscriber.confirmed = True
        subscriber.save()
        return subscriber

ConfirmSubscriptionView是另一个DetailView,因为它显示单个模型实例。在这种情况下,我们重写get_object()方法以在返回之前修改对象。由于Subscriber不需要成为我们系统的用户,我们不需要使用LoginRequiredMixin。我们的视图受到暴力枚举的保护,因为Subscriber.id的密钥空间很大,并且是非顺序分配的。

接下来,让我们在django/mailinglist/templates/mailinglist/confirm_subscription.html中创建我们的模板:

{% extends "base.html" %}

{% block title %}
  Subscription to {{ subscriber.mailing_list }} confirmed.
{% endblock %}

{% block body %}
  <h1 >Subscription to {{ subscriber.mailing_list }} confirmed!</h1 >
{% endblock %}

我们的模板使用在base.html中定义的块,简单地通知用户他们已确认订阅。

最后,让我们在mailinglist应用的 URLConf 的urlpatterns列表中添加一个path()ConfirmSubscriptionView

    path('subscribe/confirmation/<uuid:pk>',
         views.ConfirmSubscriptionView.as_view(),
         name='confirm_subscription')

我们的confirm_subscription路径定义了要匹配的路径,以便将请求路由到我们的视图。我们的匹配表达式包括 UUID 的要求,这将作为关键字参数pk传递给我们的ConfirmSubscriptionViewConfirmSubscriptionView的父类(DetailView)将使用它来检索正确的Subscriber

接下来,让我们允许Subscribers自行取消订阅。

创建 UnsubscribeView

作为道德邮件提供者的一部分,让我们的Subscriber取消订阅。接下来,我们将创建一个UnsubscribeView,在Subscriber确认他们确实想要取消订阅后,将删除Subscriber模型实例。

让我们将我们的视图添加到django/mailinglist/views.py中:

from django.views.generic import DeleteView

from mailinglist.models import Subscriber

class UnsubscribeView(DeleteView):
    model = Subscriber
    template_name = 'mailinglist/unsubscribe.html'

    def get_success_url(self):
        mailing_list = self.object.mailing_list
        return reverse('mailinglist:subscribe', kwargs={
            'mailinglist_pk': mailing_list.id
        })

我们的UnsubscribeView让 Django 内置的DeleteView实现来呈现模板,并找到并删除正确的SubscriberDeleteView要求它接收一个pk作为关键字参数,从路径中解析出Subscriberpk(就像DetailView一样)。当删除成功时,我们将使用get_success_url()方法将用户重定向到订阅页面。在执行get_success_url()时,我们的Subscriber实例已经从数据库中删除,但相应对象的副本将在self.object下可用。我们将使用内存中的(但不在数据库中的)实例来获取相关邮件列表的id属性。

要呈现确认表单,我们需要在django/mailinglist/templates/mailinglist/unsubscribe.html中创建一个模板:

{% extends "base.html" %}

{% block title %}
  Unsubscribe?
{% endblock %}

{% block body %}
  <div class="col">
    <form action="" method="post" >
      {% csrf_token %}
      <p >Are you sure you want to unsubscribe
        from {{ subscriber.mailing_list.name }}?</p >
      <input class="btn btn-danger" type="submit"
             value="Yes, I want to unsubscribe " >
    </form >
  </div >
{% endblock %}

这个模板呈现了一个POST表单,它将作为subscriber希望取消订阅的确认。

接下来,让我们向mailinglist应用的 URLConf 的urlpatterns列表中添加一个path()UnsubscribeView

     path('unsubscribe/<uuid:pk>',
         views.UnsubscribeView.as_view(),
         name='unsubscribe'),

在处理从DetailViewDeleteView派生的视图时,要记住将路径匹配器命名为pk是至关重要的。

现在,让我们允许用户开始创建他们将发送给他们的SubscriberMessage

创建消息视图

我们在Message模型中跟踪我们的用户想要发送给他们的Subscriber的电子邮件。为了确保我们有一个准确的日志记录用户发送给他们的Subscribers的内容,我们将限制Message上可用的操作。我们的用户只能创建和查看Message。支持编辑是没有意义的,因为已发送的电子邮件无法修改。我们也不会支持删除消息,这样我们和用户都有一个准确的日志记录请求发送的内容。

让我们从创建CreateMessageView开始!

创建 CreateMessageView

我们的CreateMessageView将遵循类似于我们为 Answerly 创建的 markdown 表单的模式。用户将获得一个表单,他们可以提交以保存或预览。如果提交是预览,那么表单将与Message的渲染 markdown 预览一起呈现。如果用户选择保存,那么他们将创建他们的新消息。

由于我们正在创建一个新的模型实例,我们将使用 Django 的CreateView

让我们在django/mailinglist/views.py中创建我们的视图:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView

from mailinglist.models import Message

class CreateMessageView(LoginRequiredMixin, CreateView):
    SAVE_ACTION = 'save'
    PREVIEW_ACTION = 'preview'

    form_class = MessageForm
    template_name = 'mailinglist/message_form.html'

    def get_success_url(self):
        return reverse('mailinglist:manage_mailinglist',
                       kwargs={'pk': self.object.mailing_list.id})

    def get_initial(self):
        mailing_list = self.get_mailing_list()
        return {
            'mailing_list': mailing_list.id,
        }

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        mailing_list = self.get_mailing_list()
        ctx.update({
            'mailing_list': mailing_list,
            'SAVE_ACTION': self.SAVE_ACTION,
            'PREVIEW_ACTION': self.PREVIEW_ACTION,
        })
        return ctx

    def form_valid(self, form):
        action = self.request.POST.get('action')
        if action == self.PREVIEW_ACTION:
            context = self.get_context_data(
                form=form,
                message=form.instance)
            return self.render_to_response(context=context)
        elif action == self.SAVE_ACTION:
            return super().form_valid(form)

    def get_mailing_list(self):
        mailing_list = get_object_or_404(MailingList,
                                         id=self.kwargs['mailinglist_pk'])
        if not mailing_list.user_can_use_mailing_list(self.request.user):
            raise PermissionDenied()
        return mailing_list

我们的视图继承自CreateViewLoginRequiredMixin。我们使用LoginRequiredMixin来防止未经身份验证的用户向邮件列表发送消息。为了防止已登录但未经授权的用户发送消息,我们将创建一个中心的get_mailing_list()方法,该方法检查已登录用户是否可以使用此邮件列表。get_mailing_list()期望mailinglist_pk将作为关键字参数提供给视图。

让我们仔细看看CreateMessageView,看看这些是如何一起工作的:

  • form_class = MessageForm:这是我们希望CreateView渲染、验证和用于创建我们的Message模型的表单。

  • template_name = 'mailinglist/message_form.html':这是我们接下来要创建的模板。

  • def get_success_url(): 在成功创建Message后,我们将重定向用户到MailingList的管理页面。

  • def get_initial()::我们的MessageForm将其mailing_list字段禁用,以防用户试图偷偷地为另一个用户的MailingList创建Message。相反,我们使用我们的get_mailing_list()方法来根据mailinglist_pk参数获取邮件列表。使用get_mailing_list(),我们检查已登录用户是否可以使用MailingList

  • def get_context_data(): 这提供了额外的变量给模板的上下文。我们提供了MailingList以及保存和预览的常量。

  • def form_valid(): 这定义了表单有效时的行为。我们重写了CreateView的默认行为来检查action POST 参数。action将告诉我们是要渲染Message的预览还是让CreateView保存一个新的Message模型实例。如果我们正在预览消息,那么我们将通过我们的表单构建一个未保存的Message实例传递给模板的上下文。

接下来,让我们在django/mailinglist/templates/mailinglist/message_form.html中制作我们的模板:

{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load markdownify %}
{% block title %}
  Send a message to {{ mailing_list }}
{% endblock %}

{% block body %}
  <h1 >Send a message to {{ mailing_list.name }}</h1 >
  {% if message %}
    <div class="card" >
      <div class="card-header" >
        Message Preview
      </div >
      <div class="card-body" >
        <h5 class="card-title" >{{ message.subject }}</h5 >
        <div>{{ message.body|markdownify }}</div>
      </div >
    </div >
  {% endif %}
  <form method="post" class="col-sm-12 col-md-9" >
    {% csrf_token %}
    {{ form | crispy }}
    <button type="submit" name="action"
            value="{{ SAVE_ACTION }}"
            class="btn btn-primary" >Save
    </button >
    <button type="submit" name="action"
            value="{{ PREVIEW_ACTION }}"
            class="btn btn-primary" >Preview
    </button >
  </form >
{% endblock %}

这个模板加载了第三方的 Django Markdownify 标签库和 Django Crispy Forms 标签库。前者给我们提供了markdownify过滤器,后者给我们提供了crispy过滤器。markdownify过滤器将接收到的 markdown 文本转换为 HTML。我们之前在我们的 Answerly 项目的第二部分中使用了 Django Markdownify。

这个模板表单有两个提交按钮,一个用于保存表单,一个用于预览表单。只有在我们传入message来预览时,预览块才会被渲染。

现在我们有了视图和模板,让我们在mailinglist应用的 URLConf 中为CreateMessageView添加一个path()

     path('<uuid:mailinglist_ipk>/message/new',
         views.CreateMessageView.as_view(),
         name='create_message'),

现在我们可以创建消息了,让我们创建一个查看我们已经创建的消息的视图。

创建消息 DetailView

为了让用户查看他们发送给他们的SubscriberMessage,我们需要一个MessageDetailView。这个视图将简单地显示一个Message,但应该只允许已登录并且可以使用MessageMailingList的用户访问该视图。

让我们在django/mailinglist/views.py中创建我们的视图:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView

from mailinglist.mixins import UserCanUseMailingList
from mailinglist.models import Message

class MessageDetailView(LoginRequiredMixin, UserCanUseMailingList,
                        DetailView):
    model = Message

顾名思义,我们将使用 Django 的DetailView。为了提供我们需要的保护,我们将添加 Django 的LoginRequiredMixin和我们的UserCanUseMailingList混合。正如我们以前看到的那样,我们不需要指定模板的名称,因为DetailView将根据应用和模型的名称假定它。在我们的情况下,DetailView希望模板被称为mailinglist/message_detail.html

让我们在mailinglist/message_detail.html中创建我们的模板:

{% extends "base.html" %}
{% load markdownify %}

{% block title %}
  {{ message.subject }}
{% endblock %}

{% block body %}
  <h1 >{{ message.subject }}</h1 >
  <div>
    {{ message.body|markdownify }}
  </div>
{% endblock %}

我们的模板扩展了base.html并在body块中显示消息。在显示Message.body时,我们使用第三方 Django Markdownify 标签库的markdownify过滤器将任何 markdown 文本呈现为 HTML。

最后,我们需要向mailinglist应用的 URLConf 的urlpatterns列表中添加一个path()MessageDetailView

    path('message/<uuid:pk>', 
         views.MessageDetailView.as_view(), 
         name='view_message')

我们现在已经完成了我们的mailinglist应用的模型、视图和模板。我们甚至创建了一个UserCanUseMailingList来让我们的视图轻松地阻止未经授权的用户访问MailingList或其相关视图。

接下来,我们将创建一个user应用来封装用户注册和身份验证。

创建用户应用

要在 Mail Ape 中创建一个MailingList,用户需要拥有一个帐户并已登录。在本节中,我们将编写我们的user Django 应用的代码,它将封装与用户有关的一切。请记住,Django 应用应该范围严密。我们不希望将这种行为放在我们的mailinglist应用中,因为这是两个不同的关注点。

我们的user应用将与 MyMDB(第一部分)和 Answerly(第二部分)中看到的user应用非常相似。由于这种相似性,我们将略过一些主题。要深入研究该主题,请参阅第二章,将用户添加到 MyMDb

Django 通过其内置的auth应用(django.contrib.auth)使用户和身份验证管理变得更加容易。auth应用提供了默认的用户模型、用于创建新用户的Form,以及登录和注销视图。这意味着我们的user应用只需要填写一些空白,就可以在本地完全实现用户管理。

让我们首先在django/user/urls.py中为我们的user应用创建一个 URLConf:

from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path

import user.views

app_name = 'user'

urlpatterns = [
    path('login', LoginView.as_view(), name='login'),
    path('logout', LogoutView.as_view(), name='logout'),
    path('register', user.views.RegisterView.as_view(), name='register'),
]

我们的 URLConf 由三个视图组成:

  • LoginView.as_view(): 这是auth应用的登录视图。auth应用提供了一个接受凭据的视图,但没有模板。我们需要创建一个名为registration/login.html的模板。默认情况下,它会在登录时将用户重定向到settings.LOGIN_REDIRECT_URL。我们还可以传递一个nextGET参数来取代该设置。

  • LogoutView.as_view(): 这是auth应用的注销视图。LogoutView是少数在GET请求上修改状态的视图之一,它会注销用户。该视图返回一个重定向响应。我们可以使用settings.LOGOUT_REDIRECT_URL来配置用户在注销时将被重定向到的位置。同样,我们可以使用GET参数next来自定义此行为。

  • user.views.RegisterView.as_view(): 这是我们将编写的用户注册视图。Django 为我们提供了UserCreationForm,但没有视图。

我们还需要添加一些设置,让 Django 正确使用我们的user视图。让我们在django/config/settings.py中更新一些新设置:

LOGIN_URL = 'user:login'
LOGIN_REDIRECT_URL = 'mailinglist:mailinglist_list'
LOGOUT_REDIRECT_URL = 'user:login'

这三个设置告诉 Django 如何在不同的身份验证场景下重定向用户:

  • LOGIN_URL:当未经身份验证的用户尝试访问需要身份验证的页面时,LoginRequiredMixin使用此设置。

  • LOGIN_REDIRECT_URL:当用户登录时,我们应该将他们重定向到哪里?通常,我们将他们重定向到一个个人资料页面;在我们的情况下,是显示MailingList列表的页面。

  • LOGOUT_REDIRECT_URL:当用户注销时,我们应该将他们重定向到哪里?在我们的情况下,是登录页面。

我们现在还有两项任务:

  • 创建登录模板

  • 创建用户注册视图和模板

让我们从制作登录模板开始。

创建登录模板

让我们在django/user/templates/registration/login.html中制作我们的登录模板:

{% extends "base.html" %}
{% load crispy_forms_tags %}

{% block title %} Login - {{ block.super }} {% endblock %}

{% block body %}
  <h1>Login</h1>
  <form method="post" class="col-sm-6">
    {% csrf_token %}
    {{ form|crispy }}
    <button type="submit" id="log_in" class="btn btn-primary">Log in</button>
  </form>
{% endblock %}

这个表单遵循了我们之前表单的所有做法。我们使用csrf_token来防止 CSRF 攻击。我们使用crsipy过滤器使用 Bootstrap 4 样式标签和类打印表单。

记住,我们不需要创建一个视图来处理我们的登录请求,因为我们正在使用django.contrib.auth中提供的视图。

接下来,让我们创建一个视图和模板来注册新用户。

创建用户注册视图

Django 没有为创建新用户提供视图,但它提供了一个用于捕获新用户注册的表单。我们可以将UserCreationFormCreateView结合使用,快速创建一个RegisterView

让我们在django/user/views.py中添加我们的视图:

from django.contrib.auth.forms import UserCreationForm
from django.views.generic.edit import CreateView

class RegisterView(CreateView):
    template_name = 'user/register.html'
    form_class = UserCreationForm

这是一个非常简单的CreateView,就像我们在本章中已经看到的几次一样。

让我们在django/user/templates/user/register.html中创建我们的模板:

{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block body %}
  <div class="col-sm-12">
    <h1 >Register for Mail Ape</h1 >
    <form method="post" >
      {% csrf_token %}
      {{ form | crispy }}
      <button type="submit" class="btn btn-primary" >
        Register
      </button >
    </form >
  </div >
{% endblock %}

同样,该模板遵循了我们之前CreateView模板的相同模式。

现在,我们准备在本地运行 Mail Ape。

在本地运行 Mail Ape

Django 自带开发服务器。这个服务器不适合生产(甚至是暂存)部署,但适合本地开发。

让我们使用我们 Django 项目的manage.py脚本启动服务器:

$ cd django
$ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
January 29, 2018 - 23:35:15
Django version 2.0.1, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

我们现在可以在http://127.0.0.1:8000上访问我们的服务器。

总结

在本章中,我们启动了 Mail Ape 项目。我们创建了 Django 项目并启动了两个 Django 应用程序。mailinglist应用程序包含了我们的邮件列表代码的模型、视图和模板。user应用程序包含了与用户相关的视图和模板。user应用程序要简单得多,因为它利用了 Django 的django.contrib.auth应用程序。

接下来,我们将构建一个 API,以便用户可以轻松地与 Mail Ape 集成。

第十一章:发送电子邮件的任务

现在我们有了我们的模型和视图,我们需要让 Mail Ape 发送电子邮件。我们将让 Mail Ape 发送两种类型的电子邮件,订阅者确认电子邮件和邮件列表消息。我们将通过创建一个名为SubscriberMessage的新模型来跟踪邮件列表消息的成功发送,以跟踪是否成功将消息发送给存储在Subscriber模型实例中的地址。由于向许多Subscriber模型实例发送电子邮件可能需要很长时间,我们将使用 Celery 在常规 Django 请求/响应周期之外作为任务发送电子邮件。

在本章中,我们将做以下事情:

  • 使用 Django 的模板系统生成我们电子邮件的 HTML 主体

  • 使用 Django 发送包含 HTML 和纯文本的电子邮件

  • 使用 Celery 执行异步任务

  • 防止我们的代码在测试期间发送实际电子邮件

让我们首先创建一些我们将用于发送动态电子邮件的常见资源。

创建电子邮件的常见资源

在本节中,我们将创建一个基本的 HTML 电子邮件模板和一个用于呈现电子邮件模板的Context对象。我们希望为我们的电子邮件创建一个基本的 HTML 模板,以避免重复使用样板 HTML。我们还希望确保我们发送的每封电子邮件都包含一个退订链接,以成为良好的电子邮件用户。我们的EmailTemplateContext类将始终提供我们的模板需要的常见变量。

让我们首先创建一个基本的 HTML 电子邮件模板。

创建基本的 HTML 电子邮件模板

我们将在django/mailinglist/templates/mailinglist/email/base.html中创建我们的基本电子邮件 HTML 模板:

<!DOCTYPE html>
<html lang="en" >
<head >
<body >
{% block body %}
{% endblock %}

Click <a href="{{ unsubscription_link }}">here</a> to unsubscribe from this
mailing list.
Sent with Mail Ape .
</body >
</html >

前面的模板看起来像是base.html的一个更简单的版本,只有一个块。电子邮件模板可以扩展email/base.html并覆盖主体块,以避免样板 HTML。尽管文件名相同(base.html),Django 不会混淆两者。模板是通过它们的模板路径标识的,不仅仅是文件名。

我们的基本模板还期望unsubscription_link变量始终存在。这将允许用户取消订阅,如果他们不想继续接收电子邮件。

为了确保我们的模板始终具有unsubscription_link变量,我们将创建一个Context来确保始终提供它。

创建 EmailTemplateContext

正如我们之前讨论过的(参见第一章,构建 MyMDB),要呈现模板,我们需要为 Django 提供一个Context对象,其中包含模板引用的变量。在编写基于类的视图时,我们只需要在get_context_data()方法中提供一个字典,Django 会为我们处理一切。然而,当我们想要自己呈现模板时,我们将不得不自己实例化Context类。为了确保我们所有的电子邮件模板呈现代码提供相同的最小信息,我们将创建一个自定义模板Context

让我们在django/mailinglist/emails.py中创建我们的EmailTemplateContext类:

from django.conf import settings

from django.template import Context

class EmailTemplateContext(Context):

    @staticmethod
    def make_link(path):
        return settings.MAILING_LIST_LINK_DOMAIN + path

    def __init__(self, subscriber, dict_=None, **kwargs):
        if dict_ is None:
            dict_ = {}
        email_ctx = self.common_context(subscriber)
        email_ctx.update(dict_)
        super().__init__(email_ctx, **kwargs)

    def common_context(self, subscriber):
        subscriber_pk_kwargs = {'pk': subscriber.id}
        unsubscribe_path = reverse('mailinglist:unsubscribe',
                                   kwargs=subscriber_pk_kwargs)
        return {
            'subscriber': subscriber,
            'mailing_list': subscriber.mailing_list,
            'unsubscribe_link': self.make_link(unsubscribe_path),
        }

我们的EmailTemplateContext由以下三种方法组成:

  • make_link(): 这将 URL 的路径与我们项目的MAILING_LIST_LINK_DOMAIN设置连接起来。make_link是必要的,因为 Django 的reverse()函数不包括域。Django 项目可以托管在多个不同的域上。我们将在配置电子邮件设置部分更多地讨论MAILING_LIST_LINK_DOMAIN的值。

  • __init__(): 这覆盖了Context.__init__(...)方法,给了我们一个机会将common_context()方法的结果添加到dict_参数的值中。我们要小心让参数接收到的数据覆盖我们在common_context中生成的数据。

  • common_context(): 这返回一个字典,提供我们希望所有EmailTemplateContext对象可用的变量。我们始终希望有subscribermailing_listunsubscribtion_link可用。

我们将在下一节中使用这两个资源,我们将向新的Subscriber模型实例发送确认电子邮件。

发送确认电子邮件

在本节中,我们将向新的Subscriber发送电子邮件,让他们确认对MailingList的订阅。

在本节中,我们将:

  1. 将 Django 的电子邮件配置设置添加到我们的settings.py

  2. 编写一个函数来使用 Django 的send_mail()函数发送电子邮件

  3. 创建和渲染电子邮件正文的 HTML 和文本模板

  4. 更新Subscriber.save()以在创建新的Subscriber时发送电子邮件

让我们从更新配置开始,使用我们邮件服务器的设置。

配置电子邮件设置

为了能够发送电子邮件,我们需要配置 Django 与简单邮件传输协议SMTP)服务器进行通信。在开发和学习过程中,您可能可以使用与您的电子邮件客户端相同的 SMTP 服务器。对于发送大量生产电子邮件,使用这样的服务器可能违反您的电子邮件提供商的服务条款,并可能导致帐户被暂停。请注意您使用的帐户。

让我们在django/config/settings.py中更新我们的设置:

EMAIL_HOST = 'smtp.example.com'
EMAIL_HOST_USER = 'username'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD')

MAILING_LIST_FROM_EMAIL = 'noreply@example.com'
MAILING_LIST_LINK_DOMAIN = 'http://localhost:8000'

在上面的代码示例中,我使用了很多example.com的实例,您应该将其替换为您的 SMTP 主机和域的正确域。让我们更仔细地看一下设置:

  • EMAIL_HOST: 这是我们正在使用的 SMTP 服务器的地址。

  • EMAIL_HOST_USER: 用于对 SMTP 服务器进行身份验证的用户名。

  • EMAIL_PORT: 连接到 SMTP 服务器的端口。

  • EMAIL_USE_TLS: 这是可选的,默认为False。如果您要通过 TLS 连接到 SMTP 服务器,请使用它。如果您使用 SSL,则使用EMAIL_USE_SSL设置。SSL 和 TLS 设置是互斥的。

  • EMAIL_HOST_PASSWORD: 主机的密码。在我们的情况下,我们将期望密码在环境变量中。

  • MAILING_LIST_FROM_EMAIL: 这是我们使用的自定义设置,用于设置我们发送的电子邮件的FROM标头。

  • MAILING_LIST_LINK_DOMAIN: 这是所有电子邮件模板链接的前缀域。我们在EmailTemplateContext类中看到了这个设置的使用。

接下来,让我们编写我们的创建函数来发送确认电子邮件。

创建发送电子邮件确认函数

现在,我们将创建一个实际创建并发送确认电子邮件给我们的Subscriber的函数。email模块将包含所有我们与电子邮件相关的代码(我们已经在那里创建了EmailTemplateContext类)。

我们的send_confirmation_email()函数将需要执行以下操作:

  1. 为渲染电子邮件正文创建一个Context

  2. 为电子邮件创建主题

  3. 渲染 HTML 和文本电子邮件正文

  4. 使用send_mail()函数发送电子邮件

让我们在django/mailinglist/emails.py中创建该函数:

from django.conf import settings
from django.core.mail import send_mail
from django.template import engines, Context
from django.urls import reverse

CONFIRM_SUBSCRIPTION_HTML = 'mailinglist/email/confirmation.html'

CONFIRM_SUBSCRIPTION_TXT = 'mailinglist/email/confirmation.txt'

class EmailTemplateContext(Context):
    # skipped unchanged class

def send_confirmation_email(subscriber):
    mailing_list = subscriber.mailing_list
    confirmation_link = EmailTemplateContext.make_link(
        reverse('mailinglist:confirm_subscription',
                kwargs={'pk': subscriber.id}))
    context = EmailTemplateContext(
        subscriber,
        {'confirmation_link': confirmation_link}
    )
    subject = 'Confirming subscription to {}'.format(mailing_list.name)

    dt_engine = engines['django'].engine
    text_body_template = dt_engine.get_template(CONFIRM_SUBSCRIPTION_TXT)
    text_body = text_body_template.render(context=context)
    html_body_template = dt_engine.get_template(CONFIRM_SUBSCRIPTION_HTML)
    html_body = html_body_template.render(context=context)

    send_mail(
        subject=subject,
        message=text_body,
        from_email=settings.MAILING_LIST_FROM_EMAIL,
        recipient_list=(subscriber.email,),
        html_message=html_body)

让我们更仔细地看一下我们的代码:

  • EmailTemplateContext(): 这实例化了我们之前创建的Context类。我们为其提供了一个Subscriber实例和一个包含确认链接的dictconfirmation_link变量将被我们的模板使用,我们将在接下来的两个部分中创建。

  • engines['django'].engine: 这引用了 Django 模板引擎。引擎知道如何使用settings.pyTEMPLATES设置中的配置设置来查找Template

  • dt_engine.get_template(): 这将返回一个模板对象。我们将模板的名称作为参数提供给get_template()方法。

  • text_body_template.render(): 这将模板(使用之前创建的上下文)渲染为字符串。

最后,我们使用send_email()函数发送电子邮件。send_email()函数接受以下参数:

  • subject=subject: 电子邮件消息的主题。

  • message=text_body: 电子邮件的文本版本。

  • from_email=settings.MAILING_LIST_FROM_EMAIL:发件人的电子邮件地址。如果我们不提供from_email参数,那么 Django 将使用DEFAULT_FROM_EMAIL设置。

  • recipient_list=(subscriber.email,):收件人电子邮件地址的列表(或元组)。这必须是一个集合,即使您只发送给一个收件人。如果包括多个收件人,他们将能够看到彼此。

  • html_message=html_body:电子邮件的 HTML 版本。这个参数是可选的,因为我们不必提供 HTML 正文。如果我们提供 HTML 正文,那么 Django 将发送包含 HTML 和文本正文的电子邮件。电子邮件客户端将选择显示电子邮件的 HTML 或纯文本版本。

现在我们已经有了发送电子邮件的代码,让我们制作我们的电子邮件正文模板。

创建 HTML 确认电子邮件模板

让我们制作 HTML 订阅电子邮件确认模板。我们将在django/mailinglist/templates/mailinglist/email_templates/confirmation.html中创建模板:

{% extends "mailinglist/email_templates/email_base.html" %}

{% block body %}
  <h1>Confirming subscription to {{ mailing_list }}</h1 >
  <p>Someone (hopefully you) just subscribed to {{ mailinglist }}.</p >
  <p>To confirm your subscription click <a href="{{ confirmation_link }}">here</a>.</p >
  <p>If you don't confirm, you won't hear from {{ mailinglist }} ever again.</p >
  <p>Thanks,</p >
  <p>Your friendly internet Mail Ape !</p>
{% endblock %}

我们的模板看起来就像一个 HTML 网页模板,但它将用于电子邮件。就像一个普通的 Django 模板一样,我们正在扩展一个基本模板并填写一个块。在我们的情况下,我们正在扩展的模板是我们在本章开始时创建的email/base.html模板。另外,请注意我们如何使用我们在send_confirmation_email()函数中提供的变量(例如confirmation_link)和我们的EmailTemplateContext(例如mailing_list)。

电子邮件可以包含 HTML,但并非总是由 Web 浏览器呈现。值得注意的是,一些版本的 Microsoft Outlook 使用 Microsoft Word HTML 渲染器来渲染电子邮件。即使是在运行在浏览器中的 Gmail 也会在呈现之前操纵它收到的 HTML。请小心在真实的电子邮件客户端中测试复杂的布局。

接下来,让我们创建这个模板的纯文本版本。

创建文本确认电子邮件模板

现在,我们将创建确认电子邮件模板的纯文本版本;让我们在django/mailinglist/templates/mailinglist/email_templates/confirm_subscription.txt中创建它:

Hello {{subscriber.email}},

Someone (hopefully you) just subscribed to {{ mailinglist }}.

To confirm your subscription go to {{confirmation_link}}.

If you don't confirm you won't hear from {{ mailinglist }} ever again.

Thanks,

Your friendly internet Mail Ape !

在上述情况下,我们既不使用 HTML 也不扩展任何基本模板。

然而,我们仍在引用我们在send_confirmation_email()中提供的变量(例如confirmation_link)函数和我们的EmailTemplateContext类(例如mailing_list)。

现在我们已经有了发送电子邮件所需的所有代码,让我们在创建新的Subscriber模型实例时发送它们。

在新的 Subscriber 创建时发送

作为最后一步,我们将向用户发送确认电子邮件;我们需要调用我们的send_confirmation_email函数。基于 fat models 的理念,我们将从我们的Subscriber模型而不是视图中调用我们的send_confirmation_email函数。在我们的情况下,当保存新的Subscriber模型实例时,我们将发送电子邮件。

让我们更新我们的Subscriber模型,在保存新的Subscriber时发送确认电子邮件。为了添加这种新行为,我们需要编辑django/mailinglist/models.py

from django.db import models
from mailinglist import emails

class Subscriber(models.Model):
    # skipping unchanged model body

    def save(self, force_insert=False, force_update=False, using=None,
             update_fields=None):
        is_new = self._state.adding or force_insert
        super().save(force_insert=force_insert, force_update=force_update,
                     using=using, update_fields=update_fields)
        if is_new:
            self.send_confirmation_email()

    def send_confirmation_email(self):        
           emails.send_confirmation_email(self)

在创建模型时添加新行为的最佳方法是重写模型的save()方法。在重写save()时,非常重要的是我们仍然调用超类的save()方法,以确保模型保存。我们的新保存方法有三个作用:

  • 检查当前模型是否为新模型

  • 调用超类的save()方法

  • 如果模型是新的,则发送确认电子邮件

要检查当前模型实例是否是新的,我们检查_state属性。_state属性是ModelState类的一个实例。通常,以下划线(_)开头的属性被认为是私有的,并且可能会在 Django 的不同版本中发生变化。但是,ModelState类在 Django 的官方文档中有描述,所以我们可以更放心地使用它(尽管我们应该密切关注未来版本的变化)。如果self._state.addingTrue,那么save()方法将会将这个模型实例插入为新行。如果self._state.addingTrue,那么save()方法将会更新现有行。

我们还将emails.send_confirmation_email()的调用包装在Subscriber方法中。如果我们想要重新发送确认电子邮件,这将非常有用。任何想要重新发送确认电子邮件的代码都不需要知道emails模块。模型是所有操作的专家。这是 fat model 哲学的核心。

本节的快速回顾

在本节中,我们学习了更多关于 Django 模板系统以及如何发送电子邮件。我们学会了如何渲染模板,而不是使用 Django 的内置视图来直接使用 Django 模板引擎为我们渲染它。我们使用了 Django 的最佳实践,创建了一个服务模块来隔离所有我们的电子邮件代码。最后,我们还使用了send_email()来发送一封带有文本和 HTML 正文的电子邮件。

接下来,让我们在向用户返回响应后使用 Celery 发送这些电子邮件。

使用 Celery 发送电子邮件

随着我们构建越来越复杂的应用程序,我们经常希望执行操作,而不强迫用户等待我们返回 HTTP 响应。Django 与 Celery 很好地配合,Celery 是一个流行的 Python 分布式任务队列,可以实现这一点。

Celery 是一个在代理中排队 任务以供 Celery 工作者处理的库。让我们更仔细地看看其中一些术语:

  • Celery 任务封装了我们想要异步执行的可调用对象。

  • Celery 队列是按照先进先出顺序存储在代理中的任务列表。

  • Celery 代理是提供快速高效的队列存储的服务器。流行的代理包括 RabbitMQ、Redis 和 AWS SQS。Celery 对不同代理有不同级别的支持。我们将在开发中使用 Redis 作为我们的代理。

  • Celery 工作者是单独的进程,它们检查任务队列以执行任务并执行它们。

在本节中,我们将做以下事情:

  1. 安装 Celery

  2. 配置 Celery 以与 Django 一起工作

  3. 使用 Celery 队列发送确认电子邮件任务

  4. 使用 Celery 工作者发送我们的电子邮件

让我们首先安装 Celery。

安装 celery

要安装 Celery,我们将使用这些新更改更新我们的requirements.txt文件:

celery<4.2
celery[redis]
django-celery-results<2.0

我们将安装三个新包及其依赖项:

  • celery:安装主要的 Celery 包

  • celery[redis]:安装我们需要使用 Redis 作为代理的依赖项

  • django-celery-results:让我们将执行的任务结果存储在我们的 Django 数据库中;这只是存储和记录 Celery 结果的一种方式

接下来,让我们使用pip安装我们的新包:

$ pip install -r requirements.txt

现在我们已经安装了 Celery,让我们配置 Mail Ape 来使用 Celery。

配置 Celery 设置

要配置 Celery,我们需要进行两组更改。首先,我们将更新 Django 配置以使用 Celery。其次,我们将创建一个 Celery 配置文件,供我们的工作者使用。

让我们首先更新django/config/settings.py

INSTALLED_APPS = [
    'user',
    'mailinglist',

    'crispy_forms',
    'markdownify',
    'django_celery_results',

    'django.contrib.admin',
    # other built in django apps unchanged.
]

CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'django-db'

让我们更仔细地看看这些新设置:

  • django_celery_results:这是一个我们安装为 Django 应用程序的 Celery 扩展,让我们将 Celery 任务的结果存储在 Django 数据库中。

  • CELERY_BROKER_URL:这是我们的 Celery 代理的 URL。在我们的情况下,我们将在开发中使用本地的 Redis 服务器。

  • CELERY_RESULT_BACKEND:这表示存储结果的位置。在我们的情况下,我们将使用 Django 数据库。

由于django_celery_results应用程序允许我们在数据库中保存结果,因此它包括新的 Django 模型。为了使这些模型存在于数据库中,我们需要迁移我们的数据库:

$ cd django
$ python manage.py migrate django_celery_results

接下来,让我们为我们的 Celery 工作程序创建一个配置文件。工作程序将需要访问 Django 和我们的 Celery 代理。

让我们在django/config/celery.py中创建 Celery 工作程序配置:

import os
from celery import Celery

# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

app = Celery('mailape')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

Celery 知道如何与 Django 项目直接配合。在这里,我们根据 Django 配置配置了 Celery 库的一个实例。让我们详细审查这些设置:

  • setdefault('DJANGO_SETTINGS_MODULE', ...):这确保我们的 Celery 工作程序知道如果未为DJANGO_SETTINGS_MODULE环境变量设置它,应该使用哪个 Django 设置模块。

  • Celery('mailape'):这实例化了 Mail Ape 的 Celery 库。大多数 Django 应用程序只使用一个 Celery 实例,因此mailape字符串并不重要。

  • app.config_from_object('django.conf:settings', namespace='CELERY'):这告诉我们的 Celery 库从django.conf.settings对象配置自身。namespace参数告诉 Celery 其设置以CELERY为前缀。

  • app.autodiscover_tasks():这使我们可以避免手动注册任务。当 Celery 与 Django 一起工作时,它将检查每个已安装的应用程序是否有一个tasks模块。该模块中的任何任务都将被自动发现。

通过创建一个任务来发送确认电子邮件来了解更多关于任务的信息。

创建一个任务来发送确认电子邮件

现在 Celery 已配置好,让我们创建一个任务,向订阅者发送确认电子邮件。

Celery 任务是Celery.app.task.Task的子类。但是,当我们创建 Celery 任务时,大多数情况下,我们使用 Celery 的装饰器将函数标记为任务。在 Django 项目中,使用shared_task装饰器通常是最简单的。

创建任务时,将其视为视图是有用的。Django 社区的最佳实践建议视图应该简单,这意味着视图应该简单。它们不应该负责复杂的任务,而应该将该工作委托给模型或服务模块(例如我们的mailinglist.emails模块)。

任务函数保持简单,并将所有逻辑放在模型或服务模块中。

让我们在django/mailinglist/tasks.py中创建一个任务来发送我们的确认电子邮件:

from celery import shared_task

from mailinglist import emails

@shared_task
def send_confirmation_email_to_subscriber(subscriber_id):
    from mailinglist.models import Subscriber
    subscriber = Subscriber.objects.get(id=subscriber_id)
    emails.send_confirmation_email(subscriber)

关于我们的send_confirmation_email_to_subscriber函数有一些独特的事情:

  • @shared_task:这是一个 Celery 装饰器,将函数转换为Taskshared_task对所有 Celery 实例都可用(在大多数 Django 情况下,通常只有一个)。

  • def send_confirmation_email_to_subscriber(subscriber_id)::这是一个常规函数,它以订阅者 ID 作为参数。Celery 任务可以接收任何可 pickle 的对象(包括 Django 模型)。但是,如果您传递的是可能被视为机密的内容(例如电子邮件地址),您可能希望限制存储数据的系统数量(例如,不要在代理商处存储)。在这种情况下,我们将任务函数传递给Subscriber的 ID,而不是完整的Subscriber。然后,任务函数查询相关的Subscriber实例的数据库。

在这个函数中最后要注意的一点是,我们在函数内部导入了Subscriber模型,而不是在文件顶部导入。在我们的情况下,我们的Subscriber模型将调用此任务。如果我们在tasks.py的顶部导入models模块,并在model.py的顶部导入tasks模块,那么就会出现循环导入错误。为了防止这种情况,我们在函数内部导入Subscriber

接下来,让我们从Subscriber.send_confirmation_email()中调用我们的任务。

向新订阅者发送电子邮件

现在我们有了任务,让我们更新我们的Subscriber,使用任务发送确认电子邮件,而不是直接使用emails模块。

让我们更新django/mailinglist/models.py

from django.db import models
from mailinglist import tasks

class Subscriber(models.Model):
    # skipping unchanged model 

     def send_confirmation_email(self):
        tasks.send_confirmation_email_to_subscriber.delay(self.id)

在我们更新的send_confirmation_email()方法中,我们将看看如何异步调用任务。

Celery 任务可以同步或异步调用。使用常规的()运算符,我们将同步调用任务(例如,tasks.send_confirmation_email_to_subscriber(self.id))。同步执行的任务就像常规的函数调用一样执行。

Celery 任务还有delay()方法来异步执行任务。当告诉任务要异步执行时,它将在 Celery 的消息代理中排队一条消息。然后 Celery 的 worker 将(最终)从代理的队列中拉取消息并执行任务。任务的结果存储在存储后端(在我们的情况下是 Django 数据库)中。

异步调用任务会返回一个result对象,它提供了一个get()方法。调用result.get()会阻塞当前线程,直到任务完成。然后result.get()返回任务的结果。在我们的情况下,我们的任务不会返回任何东西,所以我们不会使用result函数。

task.delay(1, a='b')实际上是task.apply_async((1,), kwargs={'a':'b'})的快捷方式。大多数情况下,快捷方法是我们想要的。如果您需要更多对任务执行的控制,apply_async()在 Celery 文档中有记录(docs.celeryproject.org/en/latest/userguide/calling.html)。

现在我们可以调用任务了,让我们启动一个 worker 来处理我们排队的任务。

启动 Celery worker

启动 Celery worker 不需要我们编写任何新代码。我们可以从命令行启动一个:

$ cd django
$ celery worker -A config.celery -l info

让我们看看我们给celery的所有参数:

  • worker: 这表示我们想要启动一个新的 worker。

  • -A config.celery: 这是我们想要使用的应用程序或配置。在我们的情况下,我们想要的应用程序在config.celery中配置。

  • -l info: 这是要输出的日志级别。在这种情况下,我们使用info。默认情况下,级别是WARNING

我们的 worker 现在能够处理 Django 中我们的代码排队的任务。如果我们发现我们排队了很多任务,我们可以启动更多的celery worker进程。

快速回顾一下这一部分

在本节中,您学会了如何使用 Celery 来异步处理任务。

我们学会了如何在我们的settings.py中使用CELERY_BROKER_URLCELERY_RESULT_BACKEND设置来设置代理和后端。我们还为我们的 celery worker 创建了一个celery.py文件。然后,我们使用@shared_task装饰器将函数变成了 Celery 任务。有了任务可用,我们学会了如何使用.delay()快捷方法调用 Celery 任务。最后,我们启动了一个 Celery worker 来执行排队的任务。

现在我们知道了基础知识,让我们使用这种方法向我们的订阅者发送消息。

向订阅者发送消息

在本节中,我们将创建代表用户想要发送到其邮件列表的消息的Message模型实例。

要发送这些消息,我们需要做以下事情:

  • 创建一个SubscriberMessage模型来跟踪哪些消息何时发送

  • 为与新的Message模型实例相关联的每个确认的Subscriber模型实例创建一个SubscriberMessage模型实例

  • SubscriberMessage模型实例向其关联的Subscriber模型实例的电子邮件发送邮件。

为了确保即使有很多相关的Subscriber模型实例的MailingList模型实例也不会拖慢我们的网站,我们将使用 Celery 来构建我们的SubscriberMessage模型实例列表发送电子邮件。

让我们首先创建一个SubscriberManager来帮助我们获取确认的Subscriber模型实例的列表。

获取确认的订阅者

良好的 Django 项目使用自定义模型管理器来集中和记录与其模型相关的QuerySet对象。我们需要一个QuerySet对象来检索属于给定MailingList模型实例的所有已确认Subscriber模型实例。

让我们更新django/mailinglist/models.py,添加一个新的SubscriberManager类,它知道如何为MailingList模型实例获取已确认的Subscriber模型实例:

class SubscriberManager(models.Manager):

    def confirmed_subscribers_for_mailing_list(self, mailing_list):
        qs = self.get_queryset()
        qs = qs.filter(confirmed=True)
        qs = qs.filter(mailing_list=mailing_list)
        return qs

class Subscriber(models.Model):
    # skipped fields 

    objects = SubscriberManager()

    class Meta:
        unique_together = ['email', 'mailing_list', ]

    # skipped methods

我们的新SubscriberManager对象取代了Subscriber.objects中的默认管理器。SubscriberManager类提供了confirmed_subscribers_for_mailing_list()方法以及默认管理器的所有方法。

接下来,让我们创建SubscriberMessage模型。

创建 SubscriberMessage 模型

现在,我们将创建一个SubscriberMessage模型和管理器。SubscriberMessage模型将让我们跟踪是否成功向Subscriber模型实例发送了电子邮件。自定义管理器将具有一个方法,用于创建Message模型实例所需的所有SubscriberMessage模型实例。

让我们从django/mailinglist/models.py中创建我们的SubscriberMessage开始:

import uuid

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

from mailinglist import tasks

class SubscriberMessage(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    message = models.ForeignKey(to=Message, on_delete=models.CASCADE)
    subscriber = models.ForeignKey(to=Subscriber, on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True)
    sent = models.DateTimeField(default=None, null=True)
    last_attempt = models.DateTimeField(default=None, null=True)

    objects = SubscriberMessageManager()

    def save(self, force_insert=False, force_update=False, using=None,
             update_fields=None):
        is_new = self._state.adding or force_insert
        super().save(force_insert=force_insert, force_update=force_update, using=using,
             update_fields=update_fields)
        if is_new:
            self.send()

    def send(self):
        tasks.send_subscriber_message.delay(self.id)

与我们其他大部分模型相比,我们的SubscriberMessage模型定制程度相当高:

  • SubsriberMessage字段将其连接到MessageSubscriber,让它跟踪创建时间、最后尝试发送电子邮件以及成功与否。

  • SubscriberMessage.objects是我们将在下一节中创建的自定义管理器。

  • SubscriberMessage.save()Subscriber.save()类似。它检查SubscriberMessage是否是新的,然后调用send()方法。

  • SubscriberMessage.send()排队一个任务来发送消息。我们将在向订阅者发送电子邮件部分稍后创建该任务。

现在,让我们在django/mailinglist/models.py中创建一个SubscriberMessageManager

from django.db import models

class SubscriberMessageManager(models.Manager):

    def create_from_message(self, message):
        confirmed_subs = Subscriber.objects.\
            confirmed_subscribers_for_mailing_list(message.mailing_list)
        return [
            self.create(message=message, subscriber=subscriber)
            for subscriber in confirmed_subs
        ]

我们的新管理器提供了一个从Message创建SubscriberMessages的方法。create_from_message()方法返回使用Manager.create()方法创建的SubscriberMessage列表。

最后,为了使新模型可用,我们需要创建一个迁移并应用它:

$ cd django
$ python manage.py makemigrations mailinglist
$ python manage.py migrate mailinglist

现在我们有了SubscriberMessage模型和表,让我们更新我们的项目,以便在创建新的Message时自动创建SubscriberMessage模型实例。

创建消息时创建 SubscriberMessages

Mail Ape 旨在在创建后立即发送消息。为了使Message模型实例成为订阅者收件箱中的电子邮件,我们需要构建一组SubscriberMessage模型实例。构建该组SubscriberMessage模型实例的最佳时间是在创建新的Message模型实例之后。

让我们在django/mailinglist/models.py中重写Message.save()

class Message(models.Model):
    # skipped fields

    def save(self, force_insert=False, force_update=False, using=None,
             update_fields=None):
        is_new = self._state.adding or force_insert
        super().save(force_insert=force_insert, force_update=force_update,
                     using=using, update_fields=update_fields)
        if is_new:
            tasks.build_subscriber_messages_for_message.delay(self.id)

我们的新Message.save()方法遵循了与之前类似的模式。Message.save()检查当前的Message是否是新的,然后是否将build_subscriber_messages_for_message任务排队等待执行。

我们将使用 Celery 异步构建一组SubscriberMessage模型实例,因为我们不知道有多少Subscriber模型实例与我们的MailingList模型实例相关联。如果有很多相关的Subscriber模型实例,那么可能会使我们的 Web 服务器无响应。使用 Celery,我们的 Web 服务器将在Message模型实例保存后立即返回响应。SubscriberMessage模型实例将由一个完全独立的进程创建。

让我们在django/mailinglist/tasks.py中创建build_subscriber_messages_for_message任务:

from celery import shared_task

@shared_task
def build_subscriber_messages_for_message(message_id):
    from mailinglist.models import Message, SubscriberMessage
    message = Message.objects.get(id=message_id)
    SubscriberMessage.objects.create_from_message(message)

正如我们之前讨论的,我们的任务本身并不包含太多逻辑。build_subscriber_messages_for_messageSubscriberMessage管理器封装了创建SubscriberMessage模型实例的所有逻辑。

接下来,让我们编写发送包含用户创建的Message的电子邮件的代码。

向订阅者发送电子邮件

本节的最后一步将是根据SubscriberMessage发送电子邮件。早些时候,我们的SubscriberMessage.save()方法排队了一个任务,向Subscriber发送Message。现在,我们将创建该任务并更新emails.py代码以发送电子邮件。

让我们从更新django/mailinglist/tasks.py开始一个新的任务:

from celery import shared_task

@shared_task
def send_subscriber_message(subscriber_message_id):
    from mailinglist.models import SubscriberMessage
    subscriber_message = SubscriberMessage.objects.get(
        id=subscriber_message_id)
    emails.send_subscriber_message(subscriber_message)

这个新任务遵循了我们之前创建的任务的相同模式:

  • 我们使用shared_task装饰器将常规函数转换为 Celery 任务

  • 我们在任务函数内导入我们的模型,以防止循环导入错误

  • 我们让emails模块来实际发送邮件

接下来,让我们更新django/mailinglist/emails.py文件,根据SubscriberMessage发送电子邮件:

from datetime import datetime

from django.conf import settings
from django.core.mail import send_mail
from django.template import engines 
from django.utils.datetime_safe import datetime

SUBSCRIBER_MESSAGE_TXT = 'mailinglist/email/subscriber_message.txt'

SUBSCRIBER_MESSAGE_HTML = 'mailinglist/email/subscriber_message.html'

def send_subscriber_message(subscriber_message):
    message = subscriber_message.message
    context = EmailTemplateContext(subscriber_message.subscriber, {
        'body': message.body,
    })

    dt_engine = engines['django'].engine
    text_body_template = dt_engine.get_template(SUBSCRIBER_MESSAGE_TXT)
    text_body = text_body_template.render(context=context)
    html_body_template = dt_engine.get_template(SUBSCRIBER_MESSAGE_HTML)
    html_body = html_body_template.render(context=context)

    utcnow = datetime.utcnow()
    subscriber_message.last_attempt = utcnow
    subscriber_message.save()

    success = send_mail(
        subject=message.subject,
        message=text_body,
        from_email=settings.MAILING_LIST_FROM_EMAIL,
        recipient_list=(subscriber_message.subscriber.email,),
        html_message=html_body)

    if success == 1:
        subscriber_message.sent = utcnow
        subscriber_message.save()

我们的新函数采取以下步骤:

  1. 使用我们之前创建的EmailTemplateContext类构建模板的上下文

  2. 使用 Django 模板引擎呈现电子邮件的文本和 HTML 版本

  3. 记录当前发送尝试的时间

  4. 使用 Django 的send_mail()函数发送电子邮件

  5. 如果send_mail()返回发送了一封电子邮件,它记录了消息发送的时间

我们的send_subscriber_message()函数要求我们创建 HTML 和文本模板来渲染。

让我们在django/mailinglist/templates/mailinglist/email_templates/subscriber_message.html中创建我们的 HTML 电子邮件正文模板:

{% extends "mailinglist/email_templates/email_base.html" %}
{% load markdownify %}

{% block body %}
  {{ body | markdownify }}
{% endblock %}

这个模板将Message的 markdown 正文呈现为 HTML。我们以前使用过markdownify标签库来将 markdown 呈现为 HTML。我们不需要 HTML 样板或包含退订链接页脚,因为email_base.html已经包含了。

接下来,我们必须在mailinglist/templates/mailinglist/email_templates/subscriber_message.txt中创建消息模板的文本版本:

{{ body }}

---

You're receiving this message because you previously subscribed to {{ mailinglist }}.

If you'd like to unsubsribe go to {{ unsubscription_link }} and click unsubscribe.

Sent with Mail Ape .

这个模板看起来非常相似。在这种情况下,我们只是将正文输出为未呈现的 markdown。此外,我们没有一个用于文本电子邮件的基本模板,所以我们必须手动编写包含退订链接的页脚。

恭喜!您现在已经更新了 Mail Ape,可以向邮件列表订阅者发送电子邮件。

确保在更改代码时重新启动您的celery worker进程。celery worker不像 Djangorunserver那样包含自动重启。如果我们不重新启动worker,那么它就不会得到任何更新的代码更改。

接下来,让我们确保我们可以在不触发 Celery 或发送实际电子邮件的情况下运行我们的测试。

测试使用 Celery 任务的代码

在这一点上,我们的两个模型将在创建时自动排队 Celery 任务。这可能会给我们在测试代码时造成问题,因为我们可能不希望在运行测试时运行 Celery 代理。相反,我们应该使用 Python 的mock库来防止在运行测试时需要运行外部系统。

我们可以使用的一种方法是使用 Python 的@patch()装饰器来装饰使用SubscriberMessage模型的每个测试方法。然而,这个手动过程很可能出错。让我们来看看一些替代方案。

在本节中,我们将看一下使模拟 Celery 任务更容易的两种方法:

  • 使用 mixin 来防止send_confirmation_email_to_subscriber任务在任何测试中被排队

  • 使用工厂来防止send_confirmation_email_to_subscriber任务被排队

通过以两种不同的方式解决相同的问题,您将了解到哪种解决方案在哪种情况下更有效。您可能会发现在项目中同时拥有这两个选项是有帮助的。

我们可以使用完全相同的方法来修补对send_mail的引用,以防止在测试期间发送邮件。

让我们首先使用一个 mixin 来应用一个补丁。

使用 TestCase mixin 来修补任务

在这种方法中,我们将创建一个 mixin,TestCase作者在编写TestCase时可以选择使用。我们在我们的 Django 代码中使用了许多 mixin 来覆盖基于类的视图的行为。现在,我们将创建一个 mixin,它将覆盖TestCase的默认行为。我们将利用每个测试方法之前调用setUp()和之后调用tearDown()的特性来设置我们的修补程序和模拟。

让我们在django/mailinglist/tests.py中创建我们的 mixin:

from unittest.mock import patch

class MockSendEmailToSubscriberTask:

    def setUp(self):
        self.send_confirmation_email_patch = patch(
            'mailinglist.tasks.send_confirmation_email_to_subscriber')
        self.send_confirmation_email_mock = self.send_confirmation_email_patch.start()
        super().setUp()

    def tearDown(self):
        self.send_confirmation_email_patch.stop()
        self.send_confirmation_email_mock = None
        super().tearDown()

我们的 mixin 的setUp()方法做了三件事:

  • 创建一个修补程序并将其保存为对象的属性

  • 启动修补程序并将生成的模拟对象保存为对象的属性,访问模拟是重要的,这样我们以后可以断言它被调用了

  • 调用父类的setUp()方法,以便正确设置TestCase

我们的 mixin 的tearDown方法还做了以下三件事:

  • 停止修补程序

  • 删除对模拟的引用

  • 调用父类的tearDown方法来完成任何其他需要发生的清理

让我们创建一个TestCase来测试SubscriberCreation,并看看我们的新MockSendEmailToSubscriberTask是如何工作的。我们将创建一个测试,使用其管理器的create()方法创建一个Subscriber模型实例。create()调用将进而调用新的Subscriber实例的save()Subscriber.save()方法应该排队一个send_confirmation_email任务。

让我们将我们的测试添加到django/mailinglist/tests.py中:

from mailinglist.models import Subscriber, MailingList

from django.contrib.auth import get_user_model
from django.test import TestCase

class SubscriberCreationTestCase(
    MockSendEmailToSubscriberTask,
    TestCase):

    def test_calling_create_queues_confirmation_email_task(self):
        user = get_user_model().objects.create_user(
            username='unit test runner'
        )
        mailing_list = MailingList.objects.create(
            name='unit test',
            owner=user,
        )
        Subscriber.objects.create(
            email='unittest@example.com',
            mailing_list=mailing_list)
        self.assertEqual(self.send_confirmation_email_mock.delay.call_count, 1)

我们的测试断言我们在 mixin 中创建的模拟已经被调用了一次。这让我们确信当我们创建一个新的Subscriber时,我们将排队正确的任务。

接下来,让我们看看如何使用 Factory Boy 工厂来解决这个问题。

使用工厂进行修补

我们在第八章中讨论了使用 Factory Boy 工厂,测试 Answerly。工厂使得创建复杂对象变得更容易。现在让我们看看如何同时使用工厂和 Python 的patch()来防止任务被排队。

让我们在django/mailinglist/factories.py中创建一个SubscriberFactory

from unittest.mock import patch

import factory

from mailinglist.models import Subscriber

class SubscriberFactory(factory.DjangoModelFactory):
    email = factory.Sequence(lambda n: 'foo.%d@example.com' % n)

    class Meta:
        model = Subscriber

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        with patch('mailinglist.models.tasks.send_confirmation_email_to_subscriber'):
            return super()._create(model_class=model_class, *args, **kwargs)

我们的工厂覆盖了默认的_create()方法,以在调用默认的_create()方法之前应用任务修补程序。当默认的_create()方法执行时,它将调用Subscriber.save(),后者将尝试排队send_confirmation_email任务。但是,该任务将被替换为模拟。一旦模型被创建并且_create()方法返回,修补程序将被移除。

现在我们可以在测试中使用我们的SubscriberFactory。让我们在django/mailinglist/tests.py中编写一个测试,以验证SubscriberManager.confirmed_subscribers_for_mailing_list()是否正确工作:

from django.contrib.auth import get_user_model
from django.test import TestCase

from mailinglist.factories import SubscriberFactory
from mailinglist.models import Subscriber, MailingList

class SubscriberManagerTestCase(TestCase):

    def testConfirmedSubscribersForMailingList(self):
        mailing_list = MailingList.objects.create(
            name='unit test',
            owner=get_user_model().objects.create_user(
                username='unit test')
        )
        confirmed_users = [
            SubscriberFactory(confirmed=True, mailing_list=mailing_list)
            for n in range(3)]
        unconfirmed_users = [
            SubscriberFactory(mailing_list=mailing_list)
            for n in range(3)]
        confirmed_users_qs = Subscriber.objects.confirmed_subscribers_for_mailing_list(
            mailing_list=mailing_list)
        self.assertEqual(len(confirmed_users), confirmed_users_qs.count())
        for user in confirmed_users_qs:
            self.assertIn(user, confirmed_users)

现在我们已经看到了两种方法,让我们来看一下这两种方法之间的一些权衡。

在修补策略之间进行选择

Factory Boy 工厂和TestCase mixin 都帮助我们解决了如何测试排队 Celery 任务的代码而不排队 Celery 任务的问题。让我们更仔细地看一些权衡。

使用 mixin 时的一些权衡如下:

  • 修补程序在整个测试期间保持不变

  • 我们可以访问生成的模拟

  • 修补程序将被应用在不需要它的测试上

  • 我们TestCase中的 mixin 由我们在代码中引用的模型所决定,这对于测试作者来说可能是一种令人困惑的间接层次

使用工厂时的一些权衡如下:

  • 如果需要,我们仍然可以访问测试中的基础函数。

  • 我们无法访问生成的模拟来断言(我们通常不需要它)。

  • 我们不将TestCaseparent class与我们在测试方法中引用的模型连接起来。对于测试作者来说更简单。

选择使用哪种方法的最终决定取决于我们正在编写的测试。

总结

在本章中,我们赋予了 Mail Ape 向我们用户的MailingList的确认Subscribers发送电子邮件的能力。我们还学会了如何使用 Celery 来处理 Django 请求/响应周期之外的任务。这使我们能够处理可能需要很长时间或需要其他资源(例如 SMTP 服务器和更多内存)的任务,而不会减慢我们的 Django Web 服务器。

本章我们涵盖了各种与电子邮件和 Celery 相关的主题。我们看到了如何配置 Django 来使用 SMTP 服务器。我们使用了 Django 的send_email()函数来发送电子邮件。我们使用@shared_task装饰器创建了一个 Celery 任务。我们使用了delay()方法将一个 Celery 任务加入队列。最后,我们探讨了一些有用的方法来测试依赖外部资源的代码。

接下来,让我们为我们的 Mail Ape 构建一个 API,这样我们的用户就可以将其集成到他们自己的网站和应用程序中。

第十二章:构建 API

现在 Mail Ape 可以向我们的订阅者发送电子邮件了,让我们让用户更容易地使用 API 与 Mail Ape 集成。在本章中,我们将构建一个 RESTful JSON API,让用户可以创建邮件列表并将订阅者添加到邮件列表中。为了简化创建我们的 API,我们将使用 Django REST 框架(DRF)。最后,我们将使用 curl 在命令行上访问我们的 API。

在本章中,我们将做以下事情:

  • 总结 DRF 的核心概念

  • 创建Serializer,定义如何解析和序列化MailingListSubscriber模型

  • 创建权限类以限制 API 对MailingList所有者的用户

  • 使用 Django REST 框架的基于类的视图来创建我们 API 的视图

  • 使用 curl 通过 HTTP 访问我们的 API

  • 在单元测试中测试我们的 API

让我们从 DRF 开始这一章。

从 Django REST 框架开始

我们将首先安装 DRF,然后审查其配置。在审查 DRF 配置时,我们将了解使其有用的功能和概念。

安装 Django REST 框架

让我们首先将 DRF 添加到我们的requirements.txt文件中:

djangorestframework<3.8

接下来,我们可以使用pip进行安装:

$ pip install -r requirements.txt

现在我们已经安装了库,让我们在django/mailinglist/settings.py文件中的INSTALLED_APPS列表中添加 DRF:

INSTALLED_APPS = [
# previously unchanged list
    'rest_framework',
]

配置 Django REST 框架

DRF 通过其视图类高度可配置。但是,我们可以使用settings.py文件中的 DRF 设置来避免在所有 DRF 视图中重复相同的常见设置。

DRF 的所有功能都源自 DRF 处理视图的方式。DRF 提供了丰富的视图集合,扩展了APIView(它又扩展了 Django 的View类)。让我们看看 APIView 的生命周期和相关设置。

DRF 视图的生命周期执行以下操作:

  1. 在 DRF 请求对象中包装 Django 的请求对象:DRF 有一个专门的Request类,它包装了 Django 的Request类,将在下面的部分中讨论。

  2. 执行内容协商:查找请求解析器和响应渲染器。

  3. 执行身份验证:检查与请求相关联的凭据。

  4. 检查权限:检查与请求相关联的用户是否可以访问此视图。

  5. 检查节流:检查最近是否有太多请求由此用户发出。

  6. 执行视图处理程序:执行与视图相关的操作(例如创建资源、查询数据库等)。

  7. 渲染响应:将响应呈现为正确的内容类型。

DRF 的自定义Request类与 Django 的Request类非常相似,只是它可以配置为解析器。DRF 视图根据视图的设置和请求的内容类型在内容协商期间找到正确的解析器。解析后的内容可以像 Django 请求与POST表单提交一样作为request.data可用。

DRF 视图还使用一个专门的Response类,它使用渲染而不是 Django 模板。渲染器是在内容协商步骤中选择的。

大部分前面的步骤都是使用可配置的类来执行的。通过在项目的settings.py中创建一个名为REST_FRAMEWORK的字典,可以配置 DRF。让我们回顾一些最重要的设置:

  • DEFAULT_PARSER_CLASSES:默认支持 JSON、表单和多部分表单。其他解析器(例如 YAML 和 MessageBuffer)可作为第三方社区包提供。

  • DEFAULT_AUTHENTICATION_CLASSES:默认支持基于会话的身份验证和 HTTP 基本身份验证。会话身份验证可以使在应用的前端使用 API 更容易。DRF 附带了一个令牌身份验证类。OAuth(1 和 2)支持可通过第三方社区包获得。

  • DEFAULT_PERMISSION_CLASSES: 默认情况下允许任何用户执行任何操作(包括更新和删除操作)。DRF 附带了一组更严格的权限,列在文档中(www.django-rest-framework.org/api-guide/permissions/#api-reference)。我们稍后还将看一下如何在本章后面创建自定义权限类。

  • DEFAULT_THROTTLE_CLASSES/DEFAULT_THROTTLE_RATES: 默认情况下为空(未限制)。DRF 提供了一个简单的节流方案,让我们可以在匿名请求和用户请求之间设置不同的速率。

  • DEFAULT_RENDERER_CLASSES: 这默认为 JSON 和browsable模板渲染器。可浏览的模板渲染器为视图和测试视图提供了一个简单的用户界面,适合开发。

我们将配置我们的 DRF 更加严格,即使在开发中也是如此。让我们在django/config/settings.py中更新以下新设置dict

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
    'DEFAULT_THROTTLE_CLASSES': (
        'rest_framework.throttling.UserRateThrottle',
        'rest_framework.throttling.AnonRateThrottle',
    ),
    'DEFAULT_THROTTLE_RATES': {
        'user': '60/minute',
        'anon': '30/minute',
    },
}

这个配置默认将 API 限制为经过身份验证的用户,并对他们的请求设置了节流。经过身份验证的用户在被节流之前可以每分钟发出 60 个请求。未经身份验证的用户可以每分钟发出 30 个请求。DRF 接受secondminutehourday的节流周期。

接下来,让我们来看一下 DRF 的Serializer

创建 Django REST Framework 序列化器

当 DRF 解析器解析请求的主体时,解析器基本上会返回一个 Python 字典。但是,在我们可以对数据执行任何操作之前,我们需要确认数据是否有效。在以前的 Django 视图中,我们会使用 Django 表单。在 DRF 中,我们使用Serializer类。

DRF 的Serializer类与 Django 表单类非常相似。两者都涉及接收验证数据和准备模型输出。但是,Serializer类不知道如何呈现其数据,而 Django 表单知道。请记住,在 DRF 视图中,渲染器负责将结果呈现为 JSON 或请求协商的任何其他格式。

就像 Django 表单一样,Serializer可以被创建来处理任意数据或基于 Django 模型。此外,Serializer由一组字段组成,我们可以用来控制序列化。当Serializer与模型相关联时,Django REST 框架知道为哪个模型Field使用哪个序列化器Field,类似于ModelForm的工作方式。

让我们在django/mailinglist/serializers.py中为我们的MailingList模型创建一个Serializer

from django.contrib.auth import get_user_model
from rest_framework import serializers

from mailinglist.models import MailingLIst

class MailingListSerializer(serializers.HyperlinkedModelSerializer):
    owner = serializers.PrimaryKeyRelatedField(
        queryset=get_user_model().objects.all())

    class Meta:
        model = MailingList
        fields = ('url', 'id', 'name', 'subscriber_set')
        read_only_fields = ('subscriber_set', )
        extra_kwargs = {
            'url': {'view_name': 'mailinglist:api-mailing-list-detail'},
            'subscriber_set': {'view_name': 'mailinglist:api-subscriber-detail'},
        }

这似乎与我们编写ModelForm的方式非常相似;让我们仔细看一下:

  • HyperlinkedModelSerializer: 这是显示到任何相关模型的超链接的Serializer类,因此当它显示MailingList的相关Subscriber模型实例时,它将显示一个链接(URL)到该实例的详细视图。

  • owner = serializers.PrimaryKeyRelatedField(...): 这改变了序列化模型的owner字段。PrimaryKeyRelatedField返回相关对象的主键。当相关模型没有序列化器或相关 API 视图时(比如 Mail Ape 中的用户模型),这是有用的。

  • model = MailingList: 告诉我们的Serializer它正在序列化哪个模型

  • fields = ('url', 'id', ...): 这列出了要序列化的模型字段。HyperlinkedModelSerializer包括一个额外的字段url,它是序列化模型详细视图的 URL。就像 Django 的ModelForm一样,ModelSerializer类(例如HyperlinkedModelSerializer)为每个模型字段有一组默认的序列化器字段。在我们的情况下,我们决定覆盖owner的表示方式(参考关于owner属性的前一点)。

  • read_only_fields = ('subscriber_set', ): 这简明地列出了哪些字段不可修改。在我们的情况下,这可以防止用户篡改Subscriber所在的邮件列表。

  • extra_kwargs: 这个字典让我们为每个字段的构造函数提供额外的参数,而不覆盖整个字段。通常是为了提供view_name参数,这是查找视图的 URL 所需的。

  • 'url': {'view_name': '...'},: 这提供了MailingList API 详细视图的名称。

  • 'subscriber_set': {'view_name': '...'},: 这提供了Subscriber API 详细视图的名称。

实际上有两种标记Serializer字段为只读的方法。一种是使用read_only_fields属性,就像前面的代码示例中那样。另一种是将read_only=True作为Field类构造函数的参数传递(例如,email = serializers.EmailField(max_length=240, read_only=True))。

接下来,我们将为我们的Subscriber模型创建两个Serializer。我们的两个订阅者将有一个区别:Subscriber.email是否可编辑。当他们创建Subscriber时,我们需要让用户写入Subscriber.email。但是,我们不希望他们在创建用户后能够更改电子邮件。

首先,让我们在django/mailinglist/serialiers.py中为Subscription模型创建一个Serializer

from rest_framework import serializers

from mailinglist.models import Subscriber

class SubscriberSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Subscriber
        fields = ('url', 'id', 'email', 'confirmed', 'mailing_list')
        extra_kwargs = {
            'url': {'view_name': 'mailinglist:api-subscriber-detail'},
            'mailing_list': {'view_name': 'mailinglist:api-mailing-list-detail'},
        }

SubscriberSerializer与我们的MailingListSerializer类似。我们使用了许多相同的元素:

  • 子类化serializers.HyperlinkedModelSerializer

  • 使用内部Meta类的model属性声明相关模型

  • 使用内部Meta类的fields属性声明相关模型的字段

  • 使用extra_kwargs字典和view_name键提供相关模型的详细视图名称。

对于我们的下一个Serializer类,我们将创建一个与SubscriberSerializer类似的类,但将email字段设置为只读;让我们将其添加到django/mailinglist/serialiers.py中:

from rest_framework import serializers

from mailinglist.models import Subscriber

class ReadOnlyEmailSubscriberSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Subscriber
        fields = ('url', 'id', 'email', 'confirmed', 'mailing_list')
        read_only_fields = ('email', 'mailing_list',)
        extra_kwargs = {
            'url': {'view_name': 'mailinglist:api-subscriber-detail'},
            'mailing_list': {'view_name': 'mailinglist:api-mailing-list-detail'},
        }

这个Serializer让我们更新Subscriber是否confirmed,但不会让Subscriberemail字段发生变化。

现在我们已经创建了一些Serializer,我们可以看到它们与 Django 内置的ModelForm有多么相似。接下来,让我们创建一个Permission类,以防止用户访问彼此的MailingListSubscriber模型实例。

API 权限

在本节中,我们将创建一个权限类,Django REST 框架将使用它来检查用户是否可以对MailingListSubscriber执行操作。这将执行与我们在第十章中创建的UserCanUseMailingList混合类非常相似的角色,开始 Mail Ape。

让我们在django/mailinglist/permissions.py中创建我们的CanUseMailingList类:

from rest_framework.permissions import BasePermission

from mailinglist.models import Subscriber, MailingList

class CanUseMailingList(BasePermission):

    message = 'User does not have access to this resource.'

    def has_object_permission(self, request, view, obj):
        user = request.user
        if isinstance(obj, Subscriber):
            return obj.mailing_list.user_can_use_mailing_list(user)
        elif isinstance(obj, MailingList):
            return obj.user_can_use_mailing_list(user)
        return False

让我们更仔细地看一下我们的CanUseMailingList类中引入的一些新元素:

  • BasePermission: 提供权限类的基本约定,实现has_permission()has_object_permission()方法,始终返回True

  • message: 这是403响应体的消息

  • def has_object_permission(...): 检查请求的用户是否是相关MailingList的所有者

CanUseMailingList类不覆盖BasePermission.has_permission(self, request, view),因为我们系统中的权限都是在对象级别而不是视图或模型级别。

如果您需要更动态的权限系统,您可能希望使用 Django 的内置权限系统(docs.djangoproject.com/en/2.0/topics/auth/default/#permissions-and-authorization)或 Django Guardian(github.com/django-guardian/django-guardian)。

现在我们有了Serializer和权限类,我们将编写我们的 API 视图。

创建我们的 API 视图

在本节中,我们将创建定义 Mail Ape 的 RESTful API 的实际视图。Django REST 框架提供了一系列基于类的视图,这些视图类似于 Django 的一系列基于类的视图。DRF 通用视图与 Django 通用视图的主要区别之一是它们如何将多个操作组合在一个单一的视图类中。例如,DRF 提供了ListCreateAPIView类,但 Django 只提供了ListView类和CreateView类。DRF 提供了ListCreateAPIView类,因为在/api/v1/mailinglists上的资源预期将提供MailingList模型实例的列表和创建端点。

Django REST 框架还提供了一套函数装饰器(www.django-rest-framework.org/api-guide/views/#function-based-views),这样你也可以使用基于函数的视图。

通过创建我们的 API 来学习更多关于 DRF 视图的知识,首先从MailingList API 视图开始。

创建 MailingList API 视图

Mail Ape 将提供一个 API 来创建、读取、更新和删除MailingList。为了支持这些操作,我们将创建以下两个视图:

  • 一个扩展了ListCreateAPIViewMailingListCreateListView

  • 一个扩展了RetrieveUpdateDestroyAPIViewMailingListRetrieveUpdateDestroyView

通过 API 列出邮件列表

为了支持获取用户的MailingList模型实例列表和创建新的MailingList模型实例,我们将在django/mailinglist/views.py中创建MailingListCreateListView类:

from rest_framework import generics
from rest_framework.permissions import IsAuthenticated

from mailinglist.permissions import CanUseMailingList
from mailinglist.serializers import MailingListSerializer

class MailingListCreateListView(generics.ListCreateAPIView):
    permission_classes = (IsAuthenticated, CanUseMailingList)
    serializer_class = MailingListSerializer

    def get_queryset(self):
        return self.request.user.mailinglist_set.all()

    def get_serializer(self, *args, **kwargs):
        if kwargs.get('data', None):
            data = kwargs.get('data', None)
            owner = {
                'owner': self.request.user.id,
            }
            data.update(owner)
        return super().get_serializer(*args, **kwargs)

让我们详细查看我们的MailingListCreateListView类:

  • ListCreateAPIView:这是我们扩展的 DRF 通用视图。它通过get_queryset()方法返回的序列化内容响应GET请求。当它收到POST请求时,它将创建并返回一个MailingList模型实例。

  • permission_classes:这是一组权限类,按顺序调用。如果IsAuthenticated失败,那么IsOwnerPermission将不会被调用。

  • serializer_class = MailingListSerializer:这是该视图使用的序列化器。

  • def get_queryset(self): 用于获取要序列化和返回的模型的QuerySet

  • def get_serializer(...): 用于获取序列化器实例。在我们的情况下,我们正在用当前登录的用户覆盖(如果有的话)从请求中收到的 owner。通过这样做,我们确保用户不能创建属于其他用户的邮件列表。这与我们可能如何在 Django 表单视图中覆盖get_initial()非常相似(例如,参考第十章中的CreateMessageView类,开始 Mail Ape)。

既然我们有了我们的视图,让我们在django/mailinglist/urls.py中添加以下代码:

   path('api/v1/mailing-list', views.MailingListCreateListView.as_view(),
         name='api-mailing-list-list'),

现在,我们可以通过向/mailinglist/api/v1/mailing-list发送请求来创建和列出MailingList模型实例。

通过 API 编辑邮件列表

接下来,让我们通过在django/mailinglist/views.py中添加一个新视图来查看、更新和删除单个MailingList模型实例。

from rest_framework import generics
from rest_framework.permissions import IsAuthenticated

from mailinglist.permissions import CanUseMailingList
from mailinglist.serializers import MailingListSerializer
from mailinglist.models import MailingList

class MailingListRetrieveUpdateDestroyView(
    generics.RetrieveUpdateDestroyAPIView):

    permission_classes = (IsAuthenticated, CanUseMailingList)
    serializer_class = MailingListSerializer
    queryset = MailingList.objects.all()

MailingListRetrieveUpdateDestroyView看起来与我们之前的视图非常相似,但是扩展了RetrieveUpdateDestroyAPIView类。像 Django 内置的DetailView一样,RetrieveUpdateDestroyAPIView期望它将在请求路径中接收到MailingList模型实例的pkRetrieveUpdateDestroyAPIView知道如何处理各种 HTTP 方法:

  • GET请求中,它检索由pk参数标识的模型

  • PUT请求中,它用收到的参数覆盖pk标识的模型的所有字段

  • PATCH请求中,仅覆盖请求中收到的字段

  • DELETE请求中,它删除由pk标识的模型

任何更新(无论是通过PUT还是PATCH)都由MailingListSerializer进行验证。

另一个区别是,我们为视图定义了一个queryset属性(MailingList.objects.all()),而不是一个get_queryset()方法。我们不需要动态限制我们的QuerySet,因为CanUseMailingList类将保护我们免受用户编辑/查看他们没有权限访问的MailingLists

就像以前一样,现在我们需要将我们的视图连接到我们应用的 URLConf 中的django/mailinglist/urls.py,使用以下代码:

   path('api/v1/mailinglist/<uuid:pk>',
         views.MailingListRetrieveUpdateDetroyView.as_view(),
         name='api-mailing-list-detail'),

请注意,我们从请求的路径中解析出<uuid:pk>参数,就像我们在一些 Django 的常规视图中对单个模型实例进行操作一样。

现在我们有了我们的MailingList API,让我们也允许我们的用户通过 API 管理Subscriber

创建订阅者 API

在这一部分,我们将创建一个 API 来管理Subscriber模型实例。这个 API 将由两个视图支持:

  • SubscriberListCreateView用于列出和创建Subscriber模型实例

  • SubscriberRetrieveUpdateDestroyView用于检索、更新和删除Subscriber模型实例

列出和创建订阅者 API

Subscriber模型实例与MailingList模型实例有一个有趣的区别,即Subscriber模型实例与用户没有直接关联。要获取Subscriber模型实例的列表,我们需要知道应该查询哪个MailingList模型实例。Subscriber模型实例的创建面临同样的问题,因此这两个操作都必须接收相关的MailingListpk来执行。

让我们从在django/mailinglist/views.py中创建我们的SubscriberListCreateView开始。

from rest_framework import generics
from rest_framework.permissions import IsAuthenticated

from mailinglist.permissions import CanUseMailingList
from mailinglist.serializers import SubscriberSerializer
from mailinglist.models import MailingList, Subscriber

class SubscriberListCreateView(generics.ListCreateAPIView):
    permission_classes = (IsAuthenticated, CanUseMailingList)
    serializer_class = SubscriberSerializer

    def get_queryset(self):
        mailing_list_pk = self.kwargs['mailing_list_pk']
        mailing_list = get_object_or_404(MailingList, id=mailing_list_pk)
        return mailing_list.subscriber_set.all()

    def get_serializer(self, *args, **kwargs):
        if kwargs.get('data'):
            data = kwargs.get('data')
            mailing_list = {
                'mailing_list': reverse(
                    'mailinglist:api-mailing-list-detail',
                    kwargs={'pk': self.kwargs['mailing_list_pk']})
            }
            data.update(mailing_list)
        return super().get_serializer(*args, **kwargs)

我们的SubscriberListCreateView类与我们的MailingListCreateListView类有很多共同之处,包括相同的基类和permission_classes属性。让我们更仔细地看看一些区别:

  • serializer_class: 使用SubscriberSerializer

  • get_queryset(): 在返回所有相关的Subscriber模型实例的QuerySet之前,检查 URL 中标识的相关MailingList模型实例是否存在。

  • get_serializer(): 确保新的Subscriber与 URL 中的MailingList相关联。我们使用reverse()函数来识别相关的MailingList模型实例,因为SubscriberSerializer类继承自HyperlinkedModelSerializer类。HyperlinkedModelSerializer希望相关模型通过超链接或路径(而不是pk)来识别。

接下来,我们将在django/mailinglist/urls.py的 URLConf 中为我们的SubscriberListCreateView类添加一个path()对象:

   path('api/v1/mailinglist/<uuid:mailing_list_pk>/subscribers',
         views.SubscriberListCreateView.as_view(),
         name='api-subscriber-list'),

在为我们的SubscriberListCreateView类添加一个path()对象时,我们需要确保有一个mailing_list_pk参数。这让SubscriberListCreateView知道要操作哪些Subscriber模型实例。

我们的用户现在可以通过我们的 RESTful API 向他们的MailingList添加Subscriber。向我们的 API 添加用户将触发确认电子邮件,因为Subscriber.save()将由我们的SubscriberSerializer调用。我们的 API 不需要知道如何发送电子邮件,因为我们的fat modelSubscriber行为的专家。

然而,这个 API 在 Mail Ape 中存在潜在的错误。我们当前的 API 允许我们添加一个已经确认的Subscriber。然而,我们的Subscriber.save()方法将向所有新的Subscriber模型实例的电子邮件地址发送确认电子邮件。这可能导致我们向已经确认的Subscriber发送垃圾邮件。为了解决这个 bug,让我们在django/mailinglist/models.py中更新Subscriber.save

class Subscriber(models.Model):
    # skipping unchanged attributes and methods

    def save(self, force_insert=False, force_update=False, using=None,
             update_fields=None):
        is_new = self._state.adding or force_insert
        super().save(force_insert=force_insert, force_update=force_update,
                     using=using, update_fields=update_fields)
        if is_new and not self.confirmed:
            self.send_confirmation_email()

现在,我们只有在保存新的未确认的Subscriber模型实例时才调用self.send_confirmation_email()

太棒了!现在,让我们创建一个视图来检索、更新和删除Subscriber模型实例。

通过 API 更新订阅者

现在,我们已经为 Subscriber 模型实例创建了列表 API 操作,我们可以创建一个 API 视图来检索、更新和删除单个Subscriber模型实例。

让我们将我们的视图添加到django/mailinglist/views.py中:

from rest_framework import generics
from rest_framework.permissions import IsAuthenticated

from mailinglist.permissions import CanUseMailingList
from mailinglist.serializers import ReadOnlyEmailSubscriberSerializer
from mailinglist.models import Subscriber

class SubscriberRetrieveUpdateDestroyView(
    generics.RetrieveUpdateDestroyAPIView):

    permission_classes = (IsAuthenticated, CanUseMailingList)
    serializer_class = ReadOnlyEmailSubscriberSerializer
    queryset = Subscriber.objects.all()

我们的SubscriberRetrieveUpdateDestroyView与我们的MailingListRetrieveUpdateDestroyView视图非常相似。两者都继承自相同的RetrieveUpdateDestroyAPIView类,以响应 HTTP 请求并使用相同的permission_classes列表提供核心行为。但是,SubscriberRetrieveUpdateDestroyView有两个不同之处:

  • serializer_class = ReadOnlyEmailSubscriberSerializer:这是一个不同的Serializer。在更新的情况下,我们不希望用户能够更改电子邮件地址。

  • queryset = Subscriber.objects.all():这是所有SubscribersQuerySet。我们不需要限制QuerySet,因为CanUseMailingList将防止未经授权的访问。

接下来,让我们确保我们可以通过将其添加到django/mailinglist/urls.py中的urlpatterns列表来路由到它:

   path('api/v1/subscriber/<uuid:pk>',
         views.SubscriberRetrieveUpdateDestroyView.as_view(),
         name='api-subscriber-detail'),

现在我们有了我们的观点,让我们尝试在命令行上与它进行交互。

运行我们的 API

在本节中,我们将在命令行上运行 Mail Ape,并使用curl在命令行上与我们的 API 进行交互,curl是一个用于与服务器交互的流行命令行工具。在本节中,我们将执行以下功能:

  • 在命令行上创建用户

  • 在命令行上创建邮件列表

  • 在命令行上获取MailingList列表

  • 在命令行上创建Subscriber

  • 在命令行上获取Subscriber列表

让我们首先使用 Django manage.py shell命令创建我们的用户:

$ cd django
$ python manage.py shell
Python 3.6.3 (default) 
Type 'copyright', 'credits' or 'license' for more information
IPython 6.2.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: from django.contrib.auth import get_user_model

In [2]: user = get_user_model().objects.create_user(username='user', password='secret')
In [3]: user.id
2

如果您已经使用 Web 界面注册了用户,可以使用该用户。此外,在生产中永远不要使用secret作为您的密码。

现在我们有了一个可以在命令行上使用的用户,让我们启动本地 Django 服务器:

$ cd django
$ python manage.py runserver

现在我们的服务器正在运行,我们可以打开另一个 shell 并获取我们用户的MailingList列表:

$ curl "http://localhost:8000/mailinglist/api/v1/mailing-list" \
     -u 'user:secret'
[]

让我们仔细看看我们的命令:

  • curl:这是我们正在使用的工具。

  • "http://... api/v1/mailing-list":这是我们发送请求的 URL。

  • -u 'user:secret':这是基本的身份验证凭据。curl会正确地对这些进行编码。

  • []:这是服务器返回的空 JSON 列表。在我们的情况下,user还没有任何MailingList

我们得到了一个 JSON 响应,因为 Django REST 框架默认配置为使用 JSON 渲染。

要为我们的用户创建一个MailingList,我们需要发送这样的POST请求:

$ curl -X "POST" "http://localhost:8000/mailinglist/api/v1/mailing-list" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -u 'user:secret' \
     -d $'{
  "name": "New List"
}'
{"url":"http://localhost:8000/mailinglist/api/v1/mailinglist/cd983e25-c6c8-48fa-9afa-1fd5627de9f1","id":"cd983e25-c6c8-48fa-9afa-1fd5627de9f1","name":"New List","owner":2,"subscriber_set":[]}

这是一个更长的命令,结果也更长。让我们来看看每个新参数:

  • -H 'Content-Type: application/json; charset=utf-8' \:这添加了一个新的 HTTP Content-Type头,告诉服务器将正文解析为 JSON。

  • -d $'{ ... }':这指定了请求的正文。在我们的情况下,我们正在发送一个 JSON 对象,其中包含新邮件列表的名称。

  • "url":"http://...cd983e25-c6c8-48fa-9afa-1fd5627de9f1":这是新MailingLIst的完整详细信息的 URL。

  • "name":"New List":这显示了我们请求的新列表的名称。

  • "owner":2:这显示了列表所有者的 ID。这与我们之前创建的用户的 ID 匹配,并包含在此请求中(使用-u)。

  • "subscriber_set":[]:这显示了此邮件列表中没有订阅者。

现在我们可以重复我们最初的请求来列出MailingList,并检查我们的新MailingList是否包含在内:

$ curl "http://localhost:8000/mailinglist/api/v1/mailing-list" \
     -u 'user:secret'
[{"url":"http://localhost:8000/mailinglist/api/v1/mailinglist/cd983e25-c6c8-48fa-9afa-1fd5627de9f1","id":"cd983e25-c6c8-48fa-9afa-1fd5627de9f1","name":"New List","owner":2,"subscriber_set":[]}]

看到我们可以在开发中运行我们的服务器和 API 是很好的,但我们不想总是依赖手动测试。让我们看看如何自动化测试我们的 API。

如果您想测试创建订阅者,请确保您的 Celery 代理(例如 Redis)正在运行,并且您有一个工作程序来消耗任务以获得完整的体验。

测试您的 API

API 通过让用户自动化他们与我们服务的交互来为我们的用户提供价值。当然,DRF 也帮助我们自动化测试我们的代码。

DRF 为我们讨论的所有常见 Django 工具提供了替代品第八章,测试 Answerly

  • Django 的RequestFactory类的APIRequestFactory

  • Django 的Client类的APIClient

  • Django 的TestCase类的APITestCase

APIRequestFactoryAPIClient使得更容易发送格式化为我们的 API 的请求。例如,它们提供了一种简单的方法来为不依赖于基于会话的认证的请求设置凭据。否则,这两个类的作用与它们的默认 Django 等效类相同。

APITestCase类简单地扩展了 Django 的TestCase类,并用APIClient替换了 Django 的Client

让我们看一个例子,我们可以添加到django/mailinglist/tests.py中:

class ListMailingListsWithAPITestCase(APITestCase):

    def setUp(self):
        password = 'password'
        username = 'unit test'
        self.user = get_user_model().objects.create_user(
            username=username,
            password=password
        )
        cred_bytes = '{}:{}'.format(username, password).encode('utf-8')
        self.basic_auth = base64.b64encode(cred_bytes).decode('utf-8')

    def test_listing_all_my_mailing_lists(self):
        mailing_lists = [
            MailingList.objects.create(
                name='unit test {}'.format(i),
                owner=self.user)
            for i in range(3)
        ]

        self.client.credentials(
            HTTP_AUTHORIZATION='Basic {}'.format(self.basic_auth))

        response = self.client.get('/mailinglist/api/v1/mailing-list')

        self.assertEqual(200, response.status_code)
        parsed = json.loads(response.content)
        self.assertEqual(3, len(parsed))

        content = str(response.content)
        for ml in mailing_lists:
            self.assertIn(str(ml.id), content)
            self.assertIn(ml.name, content)

让我们更仔细地看一下在我们的ListMailingListsWithAPITestCase类中引入的新代码:

  • class ListMailingListsWithAPITestCase(APITestCase): 这使得APITestCase成为我们的父类。APITestCase类基本上是一个TestCase类,只是用APIClient对象代替了常规的 Django Client对象分配给client属性。我们将使用这个类来测试我们的视图。

  • base64.b64encode(...): 这对我们的用户名和密码进行了 base64 编码。我们将使用这个来提供一个 HTTP 基本认证头。我们必须使用base64.b64encode()而不是base64.base64(),因为后者会引入空格来视觉上分隔长字符串。此外,我们需要对我们的字符串进行encode/decode,因为b64encode()操作byte对象。

  • client.credentials(): 这让我们设置一个认证头,以便将来由这个client对象发送所有的请求。在我们的情况下,我们发送了一个 HTTP 基本认证头。

  • json.loads(response.content): 这解析了响应内容体并返回一个 Python 列表。

  • self.assertEqual(3, len(parsed)): 这确认了解析列表中的项目数量是正确的。

如果我们使用self.client发送第二个请求,我们不需要重新认证,因为client.credentials()会记住它接收到的内容,并继续将其传递给所有请求。我们可以通过调用client.credentials()来清除凭据。

现在,我们知道如何测试我们的 API 代码了!

摘要

在本章中,我们介绍了如何使用 Django REST 框架为我们的 Django 项目创建 RESTful API。我们看到 Django REST 框架使用了与 Django 表单和 Django 通用视图类似的原则。我们还使用了 Django REST 框架中的一些核心类,我们使用了ModelSerializer来构建基于 Django 模型的Serializer,并使用了ListCreateAPIView来创建一个可以列出和创建 Django 模型的视图。我们使用了RetrieveUpdateDestroyAPIView来管理基于其主键的 Django 模型实例。

接下来,我们将使用亚马逊网络服务将我们的代码部署到互联网上。

第十三章:部署 Mail Ape

在本章中,我们将在亚马逊网络服务AWS)云中的虚拟机上部署 Mail Ape。AWS 由许多不同的服务组成。我们已经讨论过使用 S3 和在 AWS 中启动容器。在本章中,我们将使用更多的 AWS 服务。我们将使用关系数据库服务(RDS)来运行 PostgreSQL 数据库服务器。我们将使用简单队列服务(SQS)来运行 Celery 消息队列。我们将使用弹性计算云(EC2)在云中运行虚拟机。最后,我们将使用 CloudFormation 来定义我们的基础设施为代码。

在本章中,我们将做以下事情:

  • 分离生产和开发设置

  • 使用 Packer 创建我们发布的 Amazon Machine Image

  • 使用 CloudFormation 定义基础设施为代码

  • 使用命令行将 Mail Ape 部署到 AWS

让我们首先分离我们的生产开发设置。

分离开发和生产

到目前为止,我们保留了一个需求文件和一个settings.py文件。这使得开发很方便。然而,我们不能在生产中使用我们的开发设置。

当前的最佳实践是每个环境使用单独的文件。然后每个环境的文件导入一个具有共享值的通用文件。我们将为我们的需求和设置文件使用这种模式。

让我们首先分离我们的需求文件。

分离我们的需求文件

为了分离我们的需求,我们将删除现有的requirements.txt文件,并用通用、开发和生产需求文件替换它。在删除requirements.txt之后,让我们在项目的根目录下创建requirements.common.txt

django<2.1
psycopg2<2.8
django-markdownify==0.3.0
django-crispy-forms==1.7.0
celery<4.2
django-celery-results<2.0
djangorestframework<3.8
factory_boy<3.0

接下来,让我们为requirements.development.txt创建一个需求文件:

-r requirements.common.txt
celery[redis]

由于我们只在开发设置中使用 Redis,我们将在开发需求文件中保留该软件包。

我们将把我们的生产需求放在项目的根目录下的requirements.production.txt中:

-r requirements.common.txt
celery[sqs]
boto3
pycurl

为了让 Celery 与 SQS(AWS 消息队列服务)配合工作,我们需要安装 Celery SQS 库(celery[sqs])。我们还将安装boto3,Python AWS 库,和pycurl,Python 的curl实现。

接下来,让我们分离我们的 Django 设置文件。

创建通用、开发和生产设置

与我们之前的章节一样,在我们将设置分成三个文件之前,我们将通过将当前的settings.py重命名为common_settings.py然后进行一些更改来创建common_settings.py

让我们将DEBUG = False更改为,以便没有新的设置文件可以意外处于调试模式。然后,让我们通过更新SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')从环境变量中获取密钥。

在数据库配置中,我们可以删除所有凭据,但保留ENGINE(以明确表明我们打算在所有地方使用 Postgres):

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
    }
}

接下来,让我们在django/config/development_settings.py中创建一个开发设置文件:

from .common_settings import *

DEBUG = True

SECRET_KEY = 'secret key'

DATABASES['default']['NAME'] = 'mailape'
DATABASES['default']['USER'] = 'mailape'
DATABASES['default']['PASSWORD'] = 'development'
DATABASES['default']['HOST'] = 'localhost'
DATABASES['default']['PORT'] = '5432'

MAILING_LIST_FROM_EMAIL = 'mailape@example.com'
MAILING_LIST_LINK_DOMAIN = 'http://localhost'

EMAIL_HOST = 'smtp.example.com'
EMAIL_HOST_USER = 'username'
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD')
EMAIL_PORT = 587
EMAIL_USE_TLS = True

CELERY_BROKER_URL = 'redis://localhost:6379/0'

记得你需要将你的MAILING_LIST_FROM_EMAILEMAIL_HOSTEMAIL_HOST_USER更改为正确的开发数值。

接下来,让我们将我们的生产设置放在django/config/production_settings.py中:

from .common_settings import *

DEBUG = False

assert SECRET_KEY is not None, (
    'Please provide DJANGO_SECRET_KEY environment variable with a value')

ALLOWED_HOSTS += [
    os.getenv('DJANGO_ALLOWED_HOSTS'),
]

DATABASES['default'].update({
    'NAME': os.getenv('DJANGO_DB_NAME'),
    'USER': os.getenv('DJANGO_DB_USER'),
    'PASSWORD': os.getenv('DJANGO_DB_PASSWORD'),
    'HOST': os.getenv('DJANGO_DB_HOST'),
    'PORT': os.getenv('DJANGO_DB_PORT'),
})

LOGGING['handlers']['main'] = {
    'class': 'logging.handlers.WatchedFileHandler',
    'level': 'DEBUG',
    'filename': os.getenv('DJANGO_LOG_FILE')
}

MAILING_LIST_FROM_EMAIL = os.getenv('MAIL_APE_FROM_EMAIL')
MAILING_LIST_LINK_DOMAIN = os.getenv('DJANGO_ALLOWED_HOSTS')

EMAIL_HOST = os.getenv('EMAIL_HOST')
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
EMAIL_PORT = os.getenv('EMAIL_HOST_PORT')
EMAIL_USE_TLS = os.getenv('EMAIL_HOST_TLS', 'false').lower() == 'true'

CELERY_BROKER_TRANSPORT_OPTIONS = {
    'region': 'us-west-2',
    'queue_name_prefix': 'mailape-',
CELERY_BROKER_URL = 'sqs://'
}

我们的生产设置文件大部分数值都来自环境变量,这样我们就不会将生产数值提交到服务器中。有三个设置我们需要审查,如下:

  • MAILING_LIST_LINK_DOMAIN:这是我们邮件中链接的域。在我们的情况下,在前面的代码片段中,我们使用了与我们添加到ALLOWED_HOSTS列表中的相同域,确保我们正在为链接指向的域提供服务。

  • CELERY_BROKER_TRANSPORT_OPTIONS:这是一个配置 Celery 使用正确的 SQS 队列的选项字典。我们需要将区域设置为us-west-2,因为我们整个生产部署将在该区域。默认情况下,Celery 将希望使用一个名为celery的队列。然而,我们不希望该名称与我们可能部署的其他 Celery 项目发生冲突。为了防止名称冲突,我们将配置 Celery 使用mailape-前缀。

  • CELERY_BROKER_URL:这告诉 Celery 要使用哪个代理。在我们的情况下,我们使用 SQS。我们将使用 AWS 的基于角色的授权为我们的虚拟机提供对 SQS 的访问权限,这样我们就不必提供任何凭据。

现在我们已经创建了我们的生产设置,让我们在 AWS 云中创建我们的基础设施。

在 AWS 中创建基础设施堆栈

为了在 AWS 上托管应用程序,我们需要确保我们已经设置了一些基础设施。我们需要以下内容:

  • 一个 PostgreSQL 服务器

  • 安全组,以打开网络端口,以便我们可以访问我们的数据库和 Web 服务器

  • 一个 InstanceProfile,为我们部署的虚拟机提供对 SQS 的访问权限

我们可以使用 AWS Web 控制台或使用命令行界面创建所有这些。然而,随着时间的推移,如果我们依赖运行时调整,很难跟踪我们的基础设施是如何配置的。如果我们能够描述我们需要的基础设施在文件中,就像我们跟踪我们的代码一样,那将会更好。

AWS 提供了一个名为 CloudFormation 的服务,它让我们可以将基础设施视为代码。我们将使用 YAML(也可以使用 JSON,但我们将使用 YAML)在 CloudFormation 模板中定义我们的基础设施。然后,我们将执行我们的 CloudFormation 模板来创建一个 CloudFormation 堆栈。CloudFormation 堆栈将与 AWS 云中的实际资源相关联。如果我们删除 CloudFormation 堆栈,相关资源也将被删除。这使我们可以简单地控制我们对 AWS 资源的使用。

让我们在cloudformation/infrastructure.yaml中创建我们的 CloudFormation 模板。每个 CloudFormation 模板都以Description和模板格式版本信息开始。让我们从以下内容开始我们的文件:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure

我们的 CloudFormation 模板将包括以下三个部分:

  • Parameters:这是我们将在运行时传递的值。这个块是可选的,但很有用。在我们的情况下,我们将传递主数据库密码,而不是在我们的模板中硬编码它。

  • Resources:这是我们将描述的堆栈中包含的具体资源。这将描述我们的数据库服务器、SQS 队列、安全组和 InstanceProfile。

  • Outputs:这是我们将描述的值,以便更容易引用我们创建的资源。这个块是可选的,但很有用。我们将提供我们的数据库服务器地址和我们创建的 InstanceProfile 的 ID。

让我们从创建 CloudFormation 模板的Parameters块开始。

在 CloudFormation 模板中接受参数

为了避免在 CloudFormation 模板中硬编码值,我们可以接受参数。这有助于我们避免在模板中硬编码敏感值(如密码)。

让我们添加一个参数来接受数据库服务器主用户的密码:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  MasterDBPassword:
    Description: Master Password for the RDS instance
    Type: String

这为我们的模板添加了一个MasterDBPassword参数。我们以后将能够引用这个值。CloudFormation 模板让我们为参数添加两个信息:

  • Description:这不被 CloudFormation 使用,但对于必须维护我们的基础设施的人来说是有用的。

  • Type:CloudFormation 在执行我们的模板之前使用这个来检查我们提供的值是否有效。在我们的情况下,密码是一个String

接下来,让我们添加一个Resources块来定义我们基础设施中需要的 AWS 资源。

列出我们基础设施中的资源

接下来,我们将在cloudformation/infrastructure.yaml中的 CloudFormation 模板中添加一个Resources块。我们的基础设施模板将定义五个资源:

  • 安全组,将打开网络端口,允许我们访问数据库和 Web 服务器

  • 我们的数据库服务器

  • 我们的 SQS 队列

  • 允许访问 SQS 的角色

  • InstanceProfile,让我们的 Web 服务器假定上述角色

让我们首先创建安全组,这将打开我们将访问数据库和 Web 服务器的网络端口。

添加安全组

在 AWS 中,SecurityGroup 定义了一组网络访问规则,就像网络防火墙一样。默认情况下,启动的虚拟机可以发送数据到任何网络端口,但不能在任何网络端口上接受连接。这意味着我们无法使用 SSH 或 HTTP 进行连接;让我们解决这个问题。

让我们在cloudformation/infrastructure.yaml中的 CloudFormation 模板中更新三个新的安全组:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  SSHSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupName: ssh-access
      GroupDescription: permit ssh access
      SecurityGroupIngress:
        -
          IpProtocol: "tcp"
          FromPort: "22"
          ToPort: "22"
          CidrIp: "0.0.0.0/0"
  WebSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupName: web-access
      GroupDescription: permit http access
      SecurityGroupIngress:
        -
          IpProtocol: "tcp"
          FromPort: "80"
          ToPort: "80"
          CidrIp: "0.0.0.0/0"
  DatabaseSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupName: db-access
      GroupDescription: permit db access
      SecurityGroupIngress:
        -
          IpProtocol: "tcp"
          FromPort: "5432"
          ToPort: "5432"
          CidrIp: "0.0.0.0/0"

在前面的代码块中,我们定义了三个新的安全组,以打开端口22(SSH),80(HTTP)和5432(默认的 Postgres 端口)。

让我们更仔细地看一下 CloudFormation 资源的语法。每个资源块必须具有TypeProperties属性。Type属性告诉 CloudFormation 这个资源描述了什么。Properties属性描述了这个特定资源的设置。

我们使用以下属性的安全组:

  • GroupName:这提供了人性化的名称。这是可选的,但建议使用。 CloudFormation 可以为我们生成名称。安全组名称必须对于给定帐户是唯一的(例如,我不能有两个db-access组,但您和我每个人都可以有一个db-access组)。

  • GroupDescription:这是组用途的人性化描述。它是必需的。

  • SecurityGroupIngress:这是一个端口列表,用于接受此组中虚拟机的传入连接。

  • FromPort/ToPort:通常,这两个设置将具有相同的值,即您希望能够连接的网络端口。 FromPort是我们将连接的端口。 ToPort是服务正在监听的 VM 端口。

  • CidrIp:这是一个 IPv4 范围,用于接受连接。 0.0.0.0/0表示接受所有连接。

接下来,让我们将数据库服务器添加到我们的资源列表中。

添加数据库服务器

AWS 提供关系数据库服务器作为一种称为关系数据库服务RDS)的服务。要在 AWS 上创建数据库服务器,我们将创建一个新的 RDS 虚拟机(称为实例)。一个重要的事情要注意的是,当我们启动一个 RDS 实例时,我们可以连接到服务器上的 PostgreSQL 数据库,但我们没有 shell 访问权限。我们必须在不同的虚拟机上运行 Django。

让我们在cloudformation/infrastructure.yaml中的 CloudFormation 模板中添加一个 RDS 实例:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  ...
  DatabaseServer:
    Type: AWS::RDS::DBInstance
    Properties:
      DBName: mailape
      DBInstanceClass: db.t2.micro
      MasterUsername: master
      MasterUserPassword: !Ref MasterDBPassword
      Engine: postgres
      AllocatedStorage: 20
      PubliclyAccessible: true
      VPCSecurityGroups: !GetAtt DatabaseSecurityGroup.GroupId

我们的新 RDS 实例条目是AWS::RDS::DBInstance类型。让我们回顾一下我们设置的属性:

  • DBName:这是服务器的名称,而不是其中运行的任何数据库的名称。

  • DBInstanceClass:这定义了服务器虚拟机的内存和处理能力。在撰写本书时,db.t2.micro是首年免费套餐的一部分。

  • MasterUsername:这是服务器上特权管理员帐户的用户名。

  • MasterUserPassword:这是特权管理员帐户的密码

  • !Ref MasterDBPassword:这是引用MasterDBPassword参数的快捷语法。这样可以避免硬编码数据库服务器的管理员密码。

  • Engine:这是我们想要的数据库服务器类型;在我们的情况下,postgres将为我们提供一个 PostgreSQL 服务器。

  • AllocatedStorage:这表示服务器应该具有多少存储空间,以 GB 为单位。

  • PubliclyAccessible:这表示服务器是否可以从 AWS 云外部访问。

  • VPCSecurityGroups:这是一个 SecurityGroups 列表,指示哪些端口是打开和可访问的。

  • !GetAtt DatabaseSecurityGroup.GroupId: 这返回DatabaseSecurityGroup安全组的GroupID属性。

这个块还向我们介绍了 CloudFormation 的RefGetAtt函数。这两个函数让我们能够引用我们 CloudFormation 堆栈的其他部分,这是非常重要的。Ref让我们使用我们的MasterDBPassword参数作为我们数据库服务器的MasterUserPassword的值。GetAtt让我们在我们的数据库服务器的VPCSercurityGroups列表中引用我们 AWS 生成的DatabaseSecurityGroupGroupId属性。

AWS CloudFormation 提供了各种不同的函数,以使构建模板更容易。它们在 AWS 在线文档中有记录(docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html)。

接下来,让我们创建 Celery 将使用的 SQS 队列。

为 Celery 添加队列

SQS 是 AWS 消息队列服务。使用 SQS,我们可以创建一个与 Celery 兼容的消息队列,而无需维护。SQS 可以快速扩展以处理我们发送的任何请求数量。

要定义我们的队列,请将其添加到cloudformation/infrastructure.yaml中的Resources块中:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  ...
  MailApeQueue:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: mailape-celery

我们的新资源是AWS::SQS::Queue类型,并且有一个属性QueueName

接下来,让我们创建一个角色和 InstanceProfile,让我们的生产服务器访问我们的 SQS 队列。

为队列访问创建角色

早些时候,在添加安全组部分,我们讨论了创建 SecurityGroups 以打开网络端口,以便我们可以进行网络连接。为了管理 AWS 资源之间的访问,我们需要使用基于角色的授权。在基于角色的授权中,我们定义一个角色,可以被分配该角色的人(假定该角色),以及该角色可以执行哪些操作。为了使我们的 Web 服务器使用该角色,我们需要创建一个与该角色关联的 EC2 实例配置文件。

让我们首先在cloudformation/infrastructure.yaml中添加一个角色:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  ...
   SQSAccessRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          -
            Effect: "Allow"
            Principal:
              Service:
                - "ec2.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Policies:
        -
          PolicyName: "root"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              -
                Effect: Allow
                Action: "sqs:*"
                Resource: !GetAtt MailApeQueue.Arn
              -
                Effect: Allow
                Action: sqs:ListQueues
                Resource: "*"

我们的新块是AWS::IAM::Role类型。IAM 是 AWS 身份和访问管理服务的缩写。我们的角色由以下两个属性组成:

  • AssumeRolePolicyDocument:这定义了谁可以被分配这个角色。在我们的情况下,我们说这个角色可以被亚马逊的 EC2 服务中的任何对象假定。稍后,我们将在我们的 EC2 实例中使用它。

  • Policies:这是该角色允许(或拒绝)的操作列表。在我们的情况下,我们允许在我们之前定义的 SQS 队列上执行所有 SQS 操作(sqs:*)。我们通过使用GetAtt函数引用我们的队列来获取其Arn,Amazon 资源名称(ARN)。ARN 是亚马逊为亚马逊云上的每个资源提供全局唯一 ID 的方式。

现在我们有了我们的角色,我们可以将其与一个InstanceProfile资源关联起来,该资源可以与我们的 Web 服务器关联起来:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  ...
  SQSClientInstance:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Roles:
        - !Ref SQSAccessRole

我们的新 InstanceProfile 是AWS::IAM::InstanceProfile类型,并且需要一个关联角色的列表。在我们的情况下,我们只需使用Ref函数引用我们之前创建的SQSAccessRole

现在我们已经创建了我们的基础设施资源,让我们输出我们的数据库的地址和我们的InstanceProfile资源的 ARN。

输出我们的资源信息

CloudFormation 模板可以有一个输出块,以便更容易地引用创建的资源。在我们的情况下,我们将输出我们的数据库服务器的地址和InstanceProfile的 ARN。

让我们在cloudformation/infrastructure.yaml中更新我们的 CloudFormation 模板:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape Infrastructure
Parameters:
  ...
Resources:
  ...
Outputs:
  DatabaseDNS:
    Description: Public DNS of RDS database
    Value: !GetAtt DatabaseServer.Endpoint.Address
  SQSClientProfile:
    Description: Instance Profile for EC2 instances that need SQS Access
    Value: !GetAtt SQSClientInstance.Arn

在上述代码中,我们使用GetAtt函数返回我们的DatabaseServer资源的地址和我们的SQSClientInstance InstanceProfile资源的 ARN。

执行我们的模板以创建我们的资源

现在我们已经创建了我们的CloudFormation模板,我们可以创建一个CloudFormation堆栈。当我们告诉 AWS 创建我们的CloudFormation堆栈时,它将在我们的模板中创建所有相关资源。

要创建我们的模板,我们需要以下两件事情:

  • AWS 命令行界面(CLI)

  • AWS 访问密钥/秘密密钥对

我们可以使用pip安装 AWS CLI:

$ pip install awscli

要获取(或创建)您的访问密钥/秘密密钥对,您需要访问 AWS 控制台的安全凭据部分。

然后我们需要使用我们的密钥和区域配置 AWS 命令行工具。aws命令提供了一个交互式的configure子命令来完成这个任务。让我们在命令行上运行它:

$ aws configure
AWS Access Key ID [None]: <Your ACCESS key>
AWS Secret Access Key [None]: <Your secret key>
Default region name [None]: us-west-2
Default output format [None]: json

aws configure命令将您输入的值存储在主目录中的.aws目录中。

有了这些设置,我们现在可以创建我们的堆栈:

$ aws cloudformation create-stack \
    --stack-name "infrastructure" \
    --template-body "file:///path/to/mailape/cloudformation/infrastrucutre.yaml" \
    --capabilities CAPABILITY_NAMED_IAM \
    --parameters \
      "ParameterKey=MasterDBPassword,ParameterValue=password" \
    --region us-west-2

创建堆栈可能需要一些时间,因此该命令在等待成功时返回。让我们更仔细地看看我们的create-stack命令:

  • --stack-name:这是我们正在创建的堆栈的名称。堆栈名称必须在每个帐户中是唯一的。

  • --template-body:这要么是模板本身,要么是我们的情况下模板文件的file:// URL。请记住,file:// URL 需要文件的绝对路径。

  • --capabilities CAPABILITY_NAMED_IAM:这对于创建或影响Identity and Access ManagementIAM)服务的模板是必需的。这可以防止意外影响访问管理服务。

  • --parameters:这允许我们传递模板参数的值。在我们的案例中,我们将数据库的主密码设置为password,这不是一个安全的值。

  • --region:AWS 云组织为世界各地的一组区域。在我们的案例中,我们使用的是位于美国俄勒冈州一系列数据中心的us-west-2

请记住,您需要为数据库设置一个安全的主密码。

要查看堆栈创建的进度,我们可以使用 AWS Web 控制台(us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2)或使用命令行进行检查:

$ aws cloudformation describe-stacks \
    --stack-name "infrastructure" \
    --region us-west-2

当堆栈完成创建相关资源时,它将返回类似于这样的结果:

{
    "Stacks": [
        {
            "StackId": "arn:aws:cloudformation:us-west-2:XXX:stack/infrastructure/NNN",
            "StackName": "infrastructure",
            "Description": "Mail Ape Infrastructure",
            "Parameters": [
                {
                    "ParameterKey": "MasterDBPassword",
                    "ParameterValue": "password"
                }
            ],
            "StackStatus": "CREATE_COMPLETE",
            "Outputs": [
                {
                    "OutputKey": "SQSClientProfile",
                    "OutputValue": "arn:aws:iam::XXX:instance-profile/infrastructure-SQSClientInstance-XXX",
                    "Description": "Instance Profile for EC2 instances that need SQS Access"
                },
                {
                    "OutputKey": "DatabaseDNS",
                    "OutputValue": "XXX.XXX.us-west-2.rds.amazonaws.com",
                    "Description": "Public DNS of RDS database"
                }
            ],
        }
    ]
}

describe-stack结果中特别注意的两件事是:

  • Parameters键下的对象将以明文显示我们的主数据库密码

  • Outputs对象键显示了我们的InstanceProfile资源的 ARN 和数据库服务器的地址

在所有先前的代码中,我已经用 XXX 替换了特定于我的帐户的值。您的输出将有所不同。

如果您想要删除与您的堆栈关联的资源,您可以直接删除该堆栈:

$ aws cloudformation delete-stack --stack-name "infrastructure"

接下来,我们将构建一个 Amazon Machine Image,用于在 AWS 中运行 Mail Ape。

使用 Packer 构建 Amazon Machine Image

现在我们的基础设施在 AWS 中运行,让我们构建我们的 Mail Ape 服务器。在 AWS 中,我们可以启动一个官方的 Ubuntu VM,按照第九章中的步骤,部署 Answerly,并让我们的 Mail Ape 运行。但是,AWS 将 EC2 实例视为临时。如果 EC2 实例被终止,那么我们将不得不启动一个新实例并重新配置它。有几种方法可以缓解这个问题。我们将通过为我们的发布构建一个新的Amazon Machine ImageAMI)来解决临时 EC2 实例的问题。然后,每当我们使用该 AMI 启动 EC2 实例时,它将已经完美地配置好。

我们将使用 HashiCorp 的 Packer 工具自动构建我们的 AMI。 Packer 为我们提供了一种从 Packer 模板创建 AMI 的方法。 Packer 模板是一个定义了配置 EC2 实例到我们期望状态并保存 AMI 所需步骤的 JSON 文件。为了运行我们的 Packer 模板,我们还将编写一系列 shell 脚本来配置我们的 AMI。使用 Packer 这样的工具,我们可以自动构建一个新的发布 AMI。

让我们首先在我们的机器上安装 Packer。

安装 Packer

www.packer.io下载页面获取 Packer。 Packer 适用于所有主要平台。

接下来,我们将创建一个脚本来创建我们在生产中依赖的目录。

创建一个脚本来创建我们的目录结构

我们将编写的第一个脚本将为我们的所有代码创建目录。让我们在scripts/make_aws_directories.sh中添加以下脚本到我们的项目中:

#!/usr/bin/env bash
set -e

sudo mkdir -p \
    /mailape/ubuntu \
    /mailape/apache \
    /mailape/django \
    /var/log/celery \
    /etc/mailape \
    /var/log/mailape

sudo chown -R ubuntu /mailape

在上述代码中,我们使用mkdir来创建目录。接下来,我们希望让ubuntu用户可以写入/mailape目录,所以我们递归地chown/mailape目录。

所以,让我们创建一个脚本来安装我们需要的 Ubuntu 软件包。

创建一个脚本来安装我们所有的软件包

在我们的生产环境中,我们将不仅需要安装 Ubuntu 软件包,还需要安装我们已经列出的 Python 软件包。首先,让我们在ubuntu/packages.txt中列出所有我们的 Ubuntu 软件包:

python3
python3-pip
python3-dev
virtualenv
apache2
libapache2-mod-wsgi-py3
postgresql-client
libcurl4-openssl-dev
libssl-dev

接下来,让我们创建一个脚本来安装scripts/install_all_packages中的所有软件包:

#!/usr/bin/env bash
set -e

sudo apt-get update
sudo apt install -y $(cat /mailape/ubuntu/packages.txt | grep -i '^[a-z]')

virtualenv -p $(which python3) /mailape/virtualenv
source /mailape/virtualenv/bin/activate

pip install -r /mailape/requirements.production.txt

sudo chown -R www-data /var/log/mailape \
    /etc/mailape \
    /var/run/celery \
    /var/log/celery

在上述脚本中,我们将安装我们上面列出的 Ubuntu 软件包,然后创建一个virtualenv来隔离我们的 Mail Ape Python 环境和软件包。最后,我们将一些目录的所有权交给 Apache(www-data用户),以便它可以写入这些目录。我们无法给www-data用户所有权,因为直到我们安装apache2软件包之前,它们可能并不存在。

接下来,让我们配置 Apache2 使用 mod_wsgi 来运行 Mail Ape。

配置 Apache

现在,我们将添加 Apache mod_wsgi 配置,就像我们在第九章中所做的那样,部署 Answerly。 mod_wsgi 配置不是本章的重点,所以请参考第九章,部署 Answerly,了解这个配置的工作原理。

让我们为 Mail Ape 在apache/mailape.apache.conf中创建一个虚拟主机配置文件:

LogLevel info
WSGIRestrictEmbedded On

<VirtualHost *:80>

    WSGIDaemonProcess mailape \
        python-home=/mailape/virtualenv \
        python-path=/mailape/django \
        processes=2 \
        threads=2

    WSGIProcessGroup mailape

    WSGIScriptAlias / /mailape/django/config/wsgi.py
    <Directory /mailape/django/config>
        <Files wsgi.py>
            Require all granted
        </Files>
    </Directory>

    Alias /static/ /mailape/django/static_root
    <Directory /mailape/django/static_root>
        Require all granted
    </Directory>
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

正如我们在第九章中所讨论的,部署 Answerly,我们无法将环境变量传递给我们的 mod_wsgi Python 进程,因此我们需要像在第九章中所做的那样更新项目的wsgi.py

这是我们的新django/config/wsgi.py

import os
import configparser

from django.core.wsgi import get_wsgi_application

if not os.environ.get('DJANGO_SETTINGS_MODULE'):
    parser = configparser.ConfigParser()
    parser.read('/etc/mailape/mailape.ini')
    for name, val in parser['mod_wsgi'].items():
        os.environ[name.upper()] = val

application = get_wsgi_application()

我们在第九章部署 Answerly中讨论了上述脚本。这里唯一的区别是我们解析的文件,即/etc/mailape/mailape.ini

接下来,我们需要将我们的虚拟主机配置添加到 Apache 的sites-enabled目录中。让我们在scripts/configure_apache.sh中创建一个脚本来做到这一点:

#!/usr/bin/env bash

sudo rm /etc/apache2/sites-enabled/*
sudo ln -s /mailape/apache/mailape.apache.conf /etc/apache2/sites-enabled/000-mailape.conf

现在我们有了一个在生产环境中配置 Apache 的脚本,让我们配置我们的 Celery 工作进程开始。

配置 Celery

现在我们已经让 Apache 运行 Mail Ape,我们需要配置 Celery 来启动并处理我们的 SQS 队列。为了启动我们的 Celery 工作进程,我们将使用 Ubuntu 的 systemd 进程管理工具。

首先,让我们创建一个 Celery 服务文件,告诉 SystemD 如何启动 Celery。我们将在ubuntu/celery.service中创建服务文件:

[Unit]
Description=Mail Ape Celery Service
After=network.target

[Service]
Type=forking
User=www-data
Group=www-data
EnvironmentFile=/etc/mailape/celery.env
WorkingDirectory=/mailape/django
ExecStart=/bin/sh -c '/mailape/virtualenv/bin/celery multi start worker \
    -A "config.celery:app" \
    --logfile=/var/log/celery/%n%I.log --loglevel="INFO" \
    --pidfile=/run/celery/%n.pid'
ExecStop=/bin/sh -c '/mailape/virtualenv/bin/celery multi stopwait worker \
    --pidfile=/run/celery/%n.pid'
ExecReload=/bin/sh -c '/mailape/virtualenv/bin/celery multi restart worker \
   -A "config.celery:app" \
   --logfile=/var/log/celery/%n%I.log --loglevel="INFO" \
   --pidfile=/run/celery/%n.pid'

[Install]
WantedBy=multi-user.target

让我们仔细看看这个文件中的一些选项:

  • After=network.target:这意味着 SystemD 在服务器连接到网络之前不会启动这个服务。

  • Type=forking:这意味着ExecStart命令最终将启动一个新进程,该进程将继续在自己的进程 ID(PID)下运行。

  • User: 这表示将拥有 Celery 进程的用户。在我们的情况下,我们将重用 Apache 的www-data用户。

  • EnvironmentFile: 这列出了一个将用于环境变量和所有Exec命令设置的值的文件。我们列出了一个与我们的 Celery 配置(/mailape/ubuntu/celery.systemd.conf)和一个与我们的 Mail Ape 配置(/etc/mailape/celery.env)的文件。

  • ExecStart: 这是将要执行的命令,用于启动 Celery。在我们的情况下,我们启动多个 Celery 工作者。我们所有的 Celery 命令将基于它们创建的进程 ID 文件来操作我们的工作者。Celery 将用工作者的 ID 替换%n

  • ExecStop: 这是将根据它们的 PID 文件执行的命令,用于停止我们的 Celery 工作者。

  • ExecReload: 这是将执行的命令,用于重新启动我们的 Celery 工作者。Celery 支持restart命令,因此我们将使用它来执行重新启动。但是,此命令必须接收与我们的ExecStart命令相同的选项。

我们将把我们的 PID 文件放在/var/run/celery中,但我们需要确保该目录已创建。/var/run是一个特殊目录,不使用常规文件系统。我们需要创建一个配置文件,告诉 Ubuntu 创建/var/run/celery。让我们在ubuntu/tmpfiles-celery.conf中创建这个文件:

d    /run/celery   0755 www-data www-data - -

这告诉 Ubuntu 创建一个由 Apache 用户(www-data)拥有的目录/run/celery

最后,让我们创建一个脚本,将所有这些文件放在服务器的正确位置。我们将把这个脚本命名为scripts/configure_celery.sh

#!/usr/bin/env bash

sudo ln -s /mailape/ubuntu/celery.service /etc/systemd/system/celery.service
sudo ln -s /mailape/ubuntu/celery.service /etc/systemd/system/multi-user.target.wants/celery.service
sudo ln -s /mailape/ubuntu/tmpfiles-celery.conf /etc/tmpfiles.d/celery.conf

现在 Celery 和 Apache 已配置好,让我们确保它们具有正确的环境配置来运行 Mail Ape

创建环境配置文件

我们的 Celery 和 mod_wsgi Python 进程都需要从环境中提取配置信息,以连接到正确的数据库、SQS 队列和许多其他服务。这些是我们不想在版本控制系统中检查的设置和值(例如密码)。但是,我们仍然需要在生产环境中设置它们。为了创建定义我们的进程将在其中运行的环境的文件,我们将在scripts/make_mailape_environment_ini.sh中制作脚本:

#!/usr/bin/env bash

ENVIRONMENT="
DJANGO_ALLOWED_HOSTS=${WEB_DOMAIN}
DJANGO_DB_NAME=mailape
DJANGO_DB_USER=mailape
DJANGO_DB_PASSWORD=${DJANGO_DB_PASSWORD}
DJANGO_DB_HOST=${DJANGO_DB_HOST}
DJANGO_DB_PORT=5432
DJANGO_LOG_FILE=/var/log/mailape/mailape.log
DJANGO_SECRET_KEY=${DJANGO_SECRET}
DJANGO_SETTINGS_MODULE=config.production_settings
MAIL_APE_FROM_EMAIL=admin@blvdplatform.com
EMAIL_HOST=${EMAIL_HOST}
EMAIL_HOST_USER=mailape
EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
EMAIL_HOST_PORT=587
EMAIL_HOST_TLS=true

INI_FILE="[mod_wsgi]
${ENVIRONMENT}
"

echo "${INI_FILE}" | sudo tee "/etc/mailape/mailape.ini"
echo "${ENVIRONMENT}" | sudo tee "/etc/mailape/celery.env"

我们的make_mailape_environment_ini.sh脚本中有一些值是硬编码的,但引用了其他值(例如密码)作为环境变量。我们将在运行时将这些变量的值传递给 Packer。然后 Packer 将这些值传递给我们的脚本。

接下来,让我们制作 Packer 模板来构建我们的 AMI。

制作 Packer 模板

Packer 根据 Packer 模板文件中列出的指令创建 AMI。Packer 模板是一个由三个顶级键组成的 JSON 文件:

  • variables: 这将允许我们在运行时设置值(例如密码)

  • builders: 这指定了特定于云平台的详细信息,例如 AWS 凭据

  • provisioners: 这些是 Packer 将执行的指令,以制作我们的映像

让我们从packer/web_worker.json中创建我们的 Packer 模板,从variables部分开始:

{
  "variables": {
    "aws_access_key": "",
    "aws_secret_key": "",
    "django_db_password":"",
    "django_db_host":"",
    "django_secret":"",
    "email_host":"",
    "email_host_password":"",
    "mail_ape_aws_key":"",
    "mail_ape_secret_key":"",
    "sqs_celery_queue":"",
    "web_domain":""
  }
}

variables键下,我们将列出我们希望模板作为 JSON 对象键接受的所有变量。如果变量有默认值,那么我们可以将其作为该变量键的值提供。

接下来,让我们添加一个builders部分来配置 Packer 使用 AWS:

{
  "variables": {...},
  "builders": [
    {
      "type": "amazon-ebs",
      "access_key": "{{user `aws_access_key`}}",
      "secret_key": "{{user `aws_secret_key`}}",
      "region": "us-west-2",
      "source_ami": "ami-78b82400",
      "instance_type": "t2.micro",
      "ssh_username": "ubuntu",
      "ami_name": "mailape-{{timestamp}}",
      "tags": {
        "project": "mailape"
      }
    }
  ]
}

builders是一个数组,因为我们可以使用相同的模板在多个平台上构建机器映像(例如 AWS 和 Google Cloud)。让我们详细看看每个选项:

  • "type": "amazon-ebs": 告诉 Packer 我们正在创建一个带有弹性块存储的亚马逊机器映像。这是首选配置,因为它提供了灵活性。

  • "access_key": "{{user aws_access_key }}": 这是 Packer 应该使用的访问密钥,用于与 AWS 进行身份验证。Packer 包含自己的模板语言,以便可以在运行时生成值。{{ }}之间的任何值都是由 Packer 模板引擎生成的。模板引擎提供了一个user函数,它接受用户提供的变量的名称并返回其值。例如,当运行 Packer 时,{{user aws_access_key }}将被用户提供给aws_access_key的值替换。

  • "secret_key": "{{user aws_secret_key }}": 这与 AWS 秘钥相同。

  • "region": "us-west-2": 这指定了 AWS 区域。我们所有的工作都将在us-west-2中完成。

  • "source_ami": "ami-78b82400": 这是我们要定制的镜像,以制作我们的镜像。在我们的情况下,我们使用官方的 Ubuntu AMI。Ubuntu 提供了一个 EC2 AMI 定位器(cloud-images.ubuntu.com/locator/ec2/)来帮助找到他们的官方 AMI。

  • "instance_type": "t2.micro": 这是一个小型廉价的实例,在撰写本书时,属于 AWS 免费套餐。

  • "ssh_username": "ubuntu": Packer 通过 SSH 在虚拟机上执行所有操作。这是它应该用于身份验证的用户名。Packer 将为身份验证生成自己的密钥对,因此我们不必担心指定密码或密钥。

  • "ami_name": "mailape-{{timestamp}}": 结果 AMI 的名称。{{timestamp}}是一个返回自 Unix 纪元以来的 UTC 时间的函数。

  • "tags": {...}: 标记资源可以更容易地在 AWS 中识别资源。这是可选的,但建议使用。

现在我们已经指定了我们的 AWS 构建器,我们将需要指定我们的配置程序。

Packer 配置程序是定制服务器的指令。在我们的情况下,我们将使用以下两种类型的配置程序:

  • file配置程序用于将我们的代码上传到服务器。

  • shell配置程序用于执行我们的脚本和命令

首先,让我们添加我们的make_aws_directories.sh脚本,因为我们需要它首先运行:

{
  "variables": {...},
  "builders": [...],
  "provisioners": [
    {
      "type": "shell",
      "script": "{{template_dir}}/../scripts/make_aws_directories.sh"
    }
  ]
}

具有script属性的shell配置程序将上传,执行和删除脚本。Packer 提供了{{template_dir}}函数,它返回模板目录的目录。这使我们可以避免硬编码绝对路径。我们执行的第一个配置程序将执行我们在本节前面创建的make_aws_directories.sh脚本。

现在我们的目录存在了,让我们使用file配置程序将我们的代码和文件复制过去:

{
  "variables": {...},
  "builders": [...],
  "provisioners": [
    ...,
    {
      "type": "file",
      "source": "{{template_dir}}/../requirements.common.txt",
      "destination": "/mailape/requirements.common.txt"
    },
    {
      "type": "file",
      "source": "{{template_dir}}/../requirements.production.txt",
      "destination": "/mailape/requirements.production.txt"
    },
    {
      "type": "file",
      "source": "{{template_dir}}/../ubuntu",
      "destination": "/mailape/ubuntu"
    },
    {
      "type": "file",
      "source": "{{template_dir}}/../apache",
      "destination": "/mailape/apache"
    },
    {
      "type": "file",
      "source": "{{template_dir}}/../django",
      "destination": "/mailape/django"
    },
  ]
}

file配置程序将本地文件或由source定义的目录上传到destination服务器上。

由于我们从工作目录上传了 Python 代码,我们需要小心旧的.pyc文件是否还存在。让我们确保在我们的生产服务器上删除这些文件:

{
  "variables": {...},
  "builders": [...],
  "provisioners": [
    ...,
   {
      "type": "shell",
      "inline": "find /mailape/django -name '*.pyc' -delete"
   },
   ]
}

shell配置程序可以接收inline属性。然后,配置程序将在服务器上执行inline命令。

最后,让我们执行我们创建的其余脚本:

{
  "variables": {...},
  "builders": [...],
  "provisioners": [
    ...,
    {
      "type": "shell",
      "scripts": [
        "{{template_dir}}/../scripts/install_all_packages.sh",
        "{{template_dir}}/../scripts/configure_apache.sh",
        "{{template_dir}}/../scripts/make_mailape_environment_ini.sh",
        "{{template_dir}}/../scripts/configure_celery.sh"
        ],
      "environment_vars": [
        "DJANGO_DB_HOST={{user `django_db_host`}}",
        "DJANGO_DB_PASSWORD={{user `django_db_password`}}",
        "DJANGO_SECRET={{user `django_secret`}}",
        "EMAIL_HOST={{user `email_host`}}",
        "EMAIL_HOST_PASSWORD={{user `email_host_password`}}",
        "WEB_DOMAIN={{user `web_domain`}}"
      ]
}

在这种情况下,shell配置程序已收到scriptsenvironment_varsscripts是指向 shell 脚本的路径数组。数组中的每个项目都将被上传和执行。在执行每个脚本时,此shell配置程序将添加environment_vars中列出的环境变量。environment_vars参数可选地提供给所有shell配置程序,以提供额外的环境变量。

随着我们的最终配置程序添加到我们的文件中,我们现在已经完成了我们的 Packer 模板。让我们使用 Packer 来执行模板并构建我们的 Mail Ape 生产服务器。

运行 Packer 来构建 Amazon Machine Image

安装了 Packer 并创建了 Mail Ape 生产服务器 Packer 模板,我们准备构建我们的Amazon Machine Image (AMI)。

让我们运行 Packer 来构建我们的 AMI:

$ packer build \
    -var "aws_access_key=..." \
    -var "aws_secret_key=..." \
    -var "django_db_password=..." \
    -var "django_db_host=A.B.us-west-2.rds.amazonaws.com" \
    -var "django_secret=..." \
    -var "email_host=smtp.example.com" \
    -var "email_host_password=..." \
    -var "web_domain=mailape.example.com" \
    packer/web_worker.json
Build 'amazon-ebs' finished.

==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:
us-west-2: ami-XXXXXXXX

Packer 将输出我们新 AMI 镜像的 AMI ID。我们将能够使用这个 AMI 在 AWS 云中启动 EC2 实例。

如果您的模板由于缺少 Ubuntu 软件包而失败,请重试构建。在撰写本书时,Ubuntu 软件包存储库并不总是能够成功更新。

现在我们有了 AMI,我们可以部署它了。

在 AWS 上部署可扩展的自愈 Web 应用程序

现在我们有了基础架构和可部署的 AMI,我们可以在 AWS 上部署 Mail Ape。我们将使用 CloudFormation 定义一组资源,让我们根据需要扩展我们的应用程序。我们将定义以下三个资源:

  • 一个弹性负载均衡器来在我们的 EC2 实例之间分发请求

  • 一个 AutoScaling Group 来启动和终止 EC2 实例

  • 一个 LaunchConfig 来描述要启动的 EC2 实例的类型

首先,让我们确保如果需要访问任何 EC2 实例来排除部署后出现的任何问题,我们有一个 SSH 密钥。

创建 SSH 密钥对

要在 AWS 中创建 SSH 密钥对,我们可以使用以下 AWS 命令行:

$ aws ec2 create-key-pair --key-name mail_ape_production --region us-west-2
{
    "KeyFingerprint": "XXX",
    "KeyMaterial": "-----BEGIN RSA PRIVATE KEY-----\nXXX\n-----END RSA PRIVATE KEY-----",
    "KeyName": "tom-cli-test"
}

确保将KeyMaterial的值复制到您的 SSH 客户端的配置目录(通常为~/.ssh)-记得用实际的新行替换\n

接下来,让我们开始我们的 Mail Ape 部署 CloudFormation 模板。

创建 Web 服务器 CloudFormation 模板

接下来,让我们创建一个 CloudFormation 模板,将 Mail Ape 服务器部署到云中。我们将使用 CloudFormation 告诉 AWS 如何扩展我们的服务器并在灾难发生时重新启动它们。我们将告诉 CloudFormation 创建以下三个资源:

  • 一个弹性负载均衡器ELB),它将能够在我们的服务器之间分发请求

  • 一个 LaunchConfig,它将描述我们想要使用的 EC2 实例的 AMI、实例类型和其他细节。

  • 一个自动扩展组,它将监视以确保我们拥有正确数量的健康 EC2 实例。

这三个资源是构建任何类型的可扩展自愈 AWS 应用程序的核心。

让我们从cloudformation/web_worker.yaml开始构建我们的 CloudFormation 模板。我们的新模板将与cloudformation/infrastracture.yaml具有相同的三个部分:ParametersResourcesOutputs

让我们从添加Parameters部分开始。

在 web worker CloudFormation 模板中接受参数

我们的 web worker CloudFormation 模板将接受 AMI 和 InstanceProfile 作为参数进行启动。这意味着我们不必在 Packer 和基础架构堆栈中分别硬编码我们创建的资源的名称。

让我们在cloudformation/web_worker.yaml中创建我们的模板:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  WorkerAMI:
    Description: Worker AMI
    Type: String
  InstanceProfile:
    Description: the instance profile
    Type: String

现在我们有了 AMI 和 InstanceProfile 用于我们的 EC2 实例,让我们创建我们的 CloudFormation 堆栈的资源。

在我们的 web worker CloudFormation 模板中创建资源

接下来,我们将定义弹性负载均衡器ELB)、启动配置和自动扩展组。这三个资源是大多数可扩展的 AWS Web 应用程序的核心。在构建模板时,我们将看看它们是如何交互的。

首先,让我们添加我们的负载均衡器:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  ...
Resources:
  LoadBalancer:
    Type: "AWS::ElasticLoadBalancing::LoadBalancer"
    Properties:
      LoadBalancerName: MailApeLB
      Listeners:
        -
          InstancePort: 80
          LoadBalancerPort: 80
          Protocol: HTTP

在上述代码中,我们正在添加一个名为LoadBalancer的新资源,类型为AWS::ElasticLoadBalancing::LoadBalancer。ELB 需要一个名称(MailApeLB)和一个Listeners列表。每个Listeners条目应定义我们的 ELB 正在监听的端口(LoadBalancerPort)、请求将被转发到的实例端口(InstancePort)以及端口将使用的协议(在我们的情况下是HTTP)。

一个 ELB 将负责在我们启动来处理负载的任意数量的 EC2 实例之间分发 HTTP 请求。

接下来,我们将创建一个 LaunchConfig,告诉 AWS 如何启动一个新的 Mail Ape web worker:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  ...
Resources:
  LoadBalancer:
    ...
  LaunchConfig:
    Type: "AWS::AutoScaling::LaunchConfiguration"
    Properties:
      ImageId: !Ref WorkerAMI
      KeyName: mail_ape_production
      SecurityGroups:
        - ssh-access
        - web-access
      InstanceType: t2.micro
      IamInstanceProfile: !Ref InstanceProfile

Launch Config 是AWS::AutoScaling::LaunchConfiguration类型的,描述了自动扩展组应该启动的新 EC2 实例的配置。让我们逐个查看所有的Properties,以确保我们理解它们的含义:

  • ImageId:这是我们希望实例运行的 AMI 的 ID。在我们的情况下,我们使用Ref函数从WorkerAMI参数获取 AMI ID。

  • KeyName:这是将添加到此机器的 SSH 密钥的名称。如果我们需要实时排除故障,这将非常有用。在我们的情况下,我们使用了本章早期创建的 SSH 密钥对的名称。

  • SecurityGroups:这是一个定义 AWS 要打开哪些端口的安全组名称列表。在我们的情况下,我们列出了我们在基础架构堆栈中创建的 web 和 SSH 组的名称。

  • InstanceType:这表示我们的 EC2 实例的实例类型。实例类型定义了可用于我们的 EC2 实例的计算和内存资源。在我们的情况下,我们使用的是一个非常小的经济实惠的实例,(在撰写本书时)在第一年内由 AWS 免费使用。

  • IamInstanceProfile:这表示我们的 EC2 实例的InstanceProfile。在这里,我们使用Ref函数来引用InstanceProfile参数。当我们创建我们的堆栈时,我们将使用我们早期创建的 InstanceProfile 的 ARN,该 ARN 为我们的 EC2 实例访问 SQS 提供了访问权限。

接下来,我们将定义启动由 ELB 转发的请求的 EC2 实例的 AutoScaling 组:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  ...
Resources:
  LoadBalancer:
    ...
  LaunchConfig:
    ...
  WorkerGroup:
    Type: "AWS::AutoScaling::AutoScalingGroup"
    Properties:
      LaunchConfigurationName: !Ref LaunchConfig
      MinSize: 1
      MaxSize: 3
      DesiredCapacity: 1
      LoadBalancerNames:
        - !Ref LoadBalancer

我们的新自动扩展组ASG)是AWS::AutoScaling::AutoScalingGroup类型。让我们来看看它的属性:

  • LaunchConfigurationName:这是此 ASG 在启动新实例时应该使用的LaunchConfiguration的名称。在我们的情况下,我们使用Ref函数来引用我们上面创建的LaunchConfig,即启动配置。

  • MinSize/MaxSize:这些是所需的属性,设置此组可能包含的实例的最大和最小数量。这些值可以保护我们免受意外部署太多实例可能对我们的系统或每月账单产生负面影响。在我们的情况下,我们确保至少有一个(1)实例,但不超过三(3)个。

  • DesiredCapacity:这告诉我们的系统应该运行多少 ASG 和多少健康的 EC2 实例。如果一个实例失败并将健康实例的数量降到DesiredCapacity值以下,那么 ASG 将使用其启动配置来启动更多实例。

  • LoadBalancerNames:这是一个 ELB 的列表,可以将请求路由到由此 ASG 启动的实例。当新的 EC2 实例成为此 ASG 的一部分时,它也将被添加到命名 ELB 路由请求的实例列表中。在我们的情况下,我们使用Ref函数来引用我们在模板中早期定义的 ELB。

这三个工具共同帮助我们快速而顺利地扩展我们的 Django 应用程序。ASG 为我们提供了一种说出我们希望运行多少 Mail Ape EC2 实例的方法。启动配置描述了如何启动新的 Mail Ape EC2 实例。然后 ELB 将把请求分发到 ASG 启动的所有实例。

现在我们有了我们的资源,让我们输出一些最相关的数据,以使我们的部署其余部分变得容易。

输出资源名称

我们将添加到我们的 CloudFormation 模板的最后一部分是Outputs,以便更容易地记录我们的 ELB 的地址和我们的 ASG 的名称。我们需要我们 ELB 的地址来向mailape.example.com添加 CNAME 记录。如果我们需要访问我们的实例(例如,运行我们的迁移),我们将需要我们 ASG 的名称。

让我们用一个Outputs部分更新cloudformation/web_worker.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  ...
Resources:
  LoadBalancer:
    ...
  LaunchConfig:
    ...
  WorkerGroup:
    ...
Outputs:
  LoadBalancerDNS:
    Description: Load Balancer DNS name
    Value: !GetAtt LoadBalancer.DNSName
  AutoScalingGroupName:
    Description: Auto Scaling Group name
    Value: !Ref WorkerGroup

LoadBalancerDNS的值将是我们上面创建的 ELB 的 DNS 名称。AutoScalingGroupName的值将是我们的 ASG,返回 ASG 的名称。

接下来,让我们为我们的 Mail Ape 1.0 版本创建一个堆栈。

创建 Mail Ape 1.0 版本堆栈

现在我们有了我们的 Mail Ape web worker CloudFormation 模板,我们可以创建一个 CloudFormation 堆栈。创建堆栈时,堆栈将创建其相关资源,如 ELB、ASG 和 Launch Config。我们将使用 AWS CLI 来创建我们的堆栈:

$ aws cloudformation create-stack \
    --stack-name "mail_ape_1_0" \
    --template-body "file:///path/to/mailape/cloudformation/web_worker.yaml" \
    --parameters \
      "ParameterKey=WorkerAMI,ParameterValue=AMI-XXX" \
      "ParameterKey=InstanceProfile,ParameterValue=arn:aws:iam::XXX:instance-profile/XXX" \
    --region us-west-2

前面的命令看起来与我们执行创建基础设施堆栈的命令非常相似,但有一些区别:

  • --stack-name:这是我们正在创建的堆栈的名称。

  • --template-body "file:///path/...":这是一个file:// URL,其中包含我们的 CloudFormation 模板的绝对路径。由于路径前缀以两个/和 Unix 路径以/开头,因此这里会出现一个奇怪的三重/

  • --parameters:这个模板需要两个参数。我们可以以任何顺序提供它们,但必须同时提供。

  • "ParameterKey=WorkerAMI, ParameterValue=:对于WorkerAMI,我们必须提供 Packer 给我们的 AMI ID。

  • "ParameterKey=InstanceProfile,ParameterValue:对于 InstanceProfile,我们必须提供我们的基础设施堆栈输出的 Instance Profile ARN。

  • --region us-west-2:我们所有的工作都在us-west-2地区进行。

要查看我们堆栈的输出,我们可以使用 AWS CLI 的describe-stack命令:

$ aws cloudformation describe-stacks \
    --stack-name mail_ape_1_0 \
    --region us-west-2

结果是一个大的 JSON 对象;这里是一个略有缩短的示例版本:

{
    "Stacks": [
        {
            "StackId": "arn:aws:cloudformation:us-west-2:XXXX:stack/mail_ape_1_0/XXX",
            "StackName": "mail_ape_1_0",
            "Description": "Mail Ape web worker",
            "Parameters": [
                {
                    "ParameterKey": "InstanceProfile",
                    "ParameterValue": "arn:aws:iam::XXX:instance-profile/XXX"
                },
                {
                    "ParameterKey": "WorkerAMI",
                    "ParameterValue": "ami-XXX"
                }
            ],
            "StackStatus": "CREATE_COMPLETE",
            "Outputs": [
                {
                    "OutputKey": "AutoScalingGroupName",
                    "OutputValue": "mail_ape_1_0-WebServerGroup-XXX",
                    "Description": "Auto Scaling Group name"
                },
                {
                    "OutputKey": "LoadBalancerDNS",
                    "OutputValue": "MailApeLB-XXX.us-west-2.elb.amazonaws.com",
                    "Description": "Load Balancer DNS name"
                }
            ],
        }
    ]
}

我们的资源(例如 EC2 实例)直到StackStatusCREATE_COMPLETE时才会准备就绪。创建所有相关资源可能需要几分钟。

我们特别关注Outputs数组中的对象:

  • 第一个值给出了我们的 ASG 的名称。有了我们 ASG 的名称,我们就能够找到该 ASG 中的 EC2 实例,以防需要 SSH 到其中一个。

  • 第二个值给出了我们 ELB 的 DNS 名称。我们将使用我们 ELB 的 DNS 来为我们的生产 DNS 记录创建 CNAME 记录,以便将我们的流量重定向到这里(例如,为mailape.example.com创建一个 CNAME 记录,将流量重定向到我们的 ELB)。

让我们看看如何 SSH 到我们的 ASG 启动的 EC2 实例。

SSH 到 Mail Ape EC2 实例

AWS CLI 为我们提供了许多获取有关我们 EC2 实例信息的方法。让我们找到我们启动的 EC2 实例的地址:

$ aws ec2 describe-instances \
 --region=us-west-2 \
 --filters='Name=tag:aws:cloudformation:stack-name,Values=mail_ape_1_0' 

aws ec2 describe-instances命令将返回关于所有 EC2 实例的大量信息。我们可以使用--filters命令来限制返回的 EC2 实例。当我们创建一个堆栈时,许多相关资源都带有堆栈名称的标记。这使我们可以仅筛选出我们mail_ape_1_0堆栈中的 EC2 实例。

以下是输出的(大大)缩短版本:

{
  "Reservations": [
    {
      "Groups": [],
      "Instances": [
        {
          "ImageId": "ami-XXX",
          "InstanceId": "i-XXX",
          "InstanceType": "t2.micro",
          "KeyName": "mail_ape_production",
          "PublicDnsName": "ec2-XXX-XXX-XXX-XXX.us-west-2.compute.amazonaws.com",
          "PublicIpAddress": "XXX",
          "State": {
            "Name": "running"
          },
          "IamInstanceProfile": {
            "Arn": "arn:aws:iam::XXX:instance-profile/infrastructure-SQSClientInstance-XXX"
          },
          "SecurityGroups": [
            {
              "GroupName": "ssh-access"
            },
            {
              "GroupName": "web-access"
            }
          ],
          "Tags": [
            {
              "Key": "aws:cloudformation:stack-name",
              "Value": "mail_ape_1_0"
            } ] } ] } ] }

在前面的输出中,请注意PublicDnsNameKeyName。由于我们在本章前面创建了该密钥,我们可以 SSH 到这个实例:

$ ssh -i /path/to/saved/ssh/key ubuntu@ec2-XXX-XXX-XXX-XXX.us-west-2.compute.amazonaws.com

请记住,您在前面的输出中看到的XXX将在您的系统中被实际值替换。

现在我们可以 SSH 到系统中,我们可以创建和迁移我们的数据库。

创建和迁移我们的数据库

对于我们的第一个发布,我们首先需要创建我们的数据库。为了创建我们的数据库,我们将在database/make_database.sh中创建一个脚本:

#!/usr/bin/env bash

psql -v ON_ERROR_STOP=1 postgresql://$USER:$PASSWORD@$HOST/postgres <<-EOSQL
    CREATE DATABASE mailape;
    CREATE USER mailape;
    GRANT ALL ON DATABASE mailape to "mailape";
    ALTER USER mailape PASSWORD '$DJANGO_DB_PASSWORD';
    ALTER USER mailape CREATEDB;
EOSQL

此脚本使用其环境中的三个变量:

  • $USER:Postgres 主用户用户名。我们在cloudformation/infrastructure.yaml中将其定义为master

  • $PASSWORD:Postgres 主用户的密码。我们在创建infrastructure堆栈时将其作为参数提供。

  • $DJANGO_DB_PASSWORD:这是 Django 数据库的密码。我们在创建 AMI 时将其作为参数提供给 Packer。

接下来,我们将通过提供变量来在本地执行此脚本:

$ export USER=master
$ export PASSWORD=...
$ export DJANGO_DB_PASSWORD=...
$ bash database/make_database.sh

我们的 Mail Ape 数据库现在已经创建。

接下来,让我们 SSH 到我们的新 EC2 实例并运行我们的数据库迁移:

$ ssh -i /path/to/saved/ssh/key ubuntu@ec2-XXX-XXX-XXX-XXX.us-west-2.compute.amazonaws.com
$ source /mailape/virtualenv/bin/activate
$ cd /mailape/django
$ export DJANGO_DB_NAME=mailape
$ export DJANGO_DB_USER=mailape
$ export DJANGO_DB_PASSWORD=...
$ export DJANGO_DB_HOST=XXX.XXX.us-west-2.rds.amazonaws.com
$ export DJANGO_DB_PORT=5432
$ export DJANGO_LOG_FILE=/var/log/mailape/mailape.log
$ export DJANGO_SECRET_KEY=...
$ export DJANGO_SETTINGS_MODULE=config.production_settings
$ python manage.py migrate

我们的manage.py migrate命令与我们在以前章节中使用的非常相似。这里的主要区别在于我们需要首先 SSH 到我们的生产 EC2 实例。

migrate返回成功时,我们的数据库已经准备好,我们可以发布我们的应用程序了。

发布 Mail Ape 1.0

现在我们已经迁移了我们的数据库,我们准备更新mailape.example.com的 DNS 记录,指向我们 ELB 的 DNS 记录。一旦 DNS 记录传播,Mail Ape 就会上线。

恭喜!

使用 update-stack 进行扩展和缩小

使用 CloudFormation 和 Auto Scaling Groups 的一个很棒的地方是,很容易扩展我们的系统。在本节中,让我们更新我们的系统,使用两个运行 Mail Ape 的 EC2 实例。

我们可以在cloudformation/web_worker.yaml中更新我们的 CloudFormation 模板:

AWSTemplateFormatVersion: "2010-09-09"
Description: Mail Ape web worker
Parameters:
  ..
Resources:
  LoadBalancer:
    ...
  LaunchConfig:
    ...
  WorkerGroup:
    Type: "AWS::AutoScaling::AutoScalingGroup"
    Properties:
      LaunchConfigurationName: !Ref LaunchConfig
      MinSize: 1
      MaxSize: 3
      DesiredCapacity: 2
      LoadBalancerNames:
        - !Ref LoadBalancer
Outputs:
  ..

我们已经将DesiredCapacity从 1 更新为 2。现在,我们不再创建新的堆栈,而是更新现有的堆栈:

$ aws cloudformation update-stack \
    --stack-name "mail_ape_1_0" \
    --template-body "file:///path/to/mailape/cloudformation/web_worker.yaml" \
    --parameters \
      "ParameterKey=WorkerAMI,UsePreviousValue=true" \
      "ParameterKey=InstanceProfile,UsePreviousValue=true" \
    --region us-west-2

前面的命令看起来很像我们的create-stack命令。一个方便的区别是我们不需要再次提供参数值 - 我们可以简单地通知UsePreviousValue=true告诉 AWS 重用之前的相同值。

同样,describe-stack会告诉我们更新何时完成:

aws cloudformation describe-stacks \
    --stack-name mail_ape_1_0 \
    --region us-west-2

结果是一个大型的 JSON 对象 - 这里是一个截断的示例版本:

{
    "Stacks": [
        {
            "StackId": "arn:aws:cloudformation:us-west-2:XXXX:stack/mail_ape_1_0/XXX",
            "StackName": "mail_ape_1_0",
            "Description": "Mail Ape web worker",
            "StackStatus": "UPDATE_COMPLETE"
        }
    ]
}

一旦我们的StackStatusUPDATE_COMPLETE,我们的 ASG 将使用新的设置进行更新。ASG 可能需要几分钟来启动新的 EC2 实例,但我们可以使用我们之前创建的describe-instances命令来查找它:

$ aws ec2 describe-instances \
 --region=us-west-2 \
 --filters='Name=tag:aws:cloudformation:stack-name,Values=mail_ape_1_0'

最终,它将返回两个实例。以下是输出的高度截断版本:

{
  "Reservations": [
    {
      "Groups": [],
      "Instances": [
        {
          "ImageId": "ami-XXX",
          "InstanceId": "i-XXX",
          "PublicDnsName": "ec2-XXX-XXX-XXX-XXX.us-west-2.compute.amazonaws.com",
          "State": { "Name": "running" }
        },
        {
          "ImageId": "ami-XXX",
          "InstanceId": "i-XXX",
          "PublicDnsName": "ec2-XXX-XXX-XXX-XXX.us-west-2.compute.amazonaws.com",
          "State": { "Name": "running" }
        } ] } ] }

要缩小到一个实例,只需更新您的web_worker.yaml模板并再次运行update-stack

恭喜!您现在知道如何将 Mail Ape 扩展到处理更高的负载,然后在非高峰时期缩小规模。

请记住,亚马逊的收费是基于使用情况的。如果您在阅读本书的过程中进行了扩展,请记住要缩小规模,否则您可能会被收取比预期更多的费用。确保您阅读关于 AWS 免费套餐限制的信息aws.amazon.com/free/

总结

在本章中,我们将我们的 Mail Ape 应用程序并在 AWS 云中的生产环境中启动。我们使用 AWS CloudFormation 将我们的 AWS 资源声明为代码,使得跟踪我们需要的内容和发生了什么变化就像在我们的代码库的其余部分一样容易。我们使用 Packer 构建了我们的 Mail Ape 服务器运行的镜像,再次使我们能够将我们的服务器配置作为代码进行跟踪。最后,我们将 Mail Ape 启动到云中,并学会了如何进行扩展和缩小。

现在我们已经完成了学习构建 Django Web 应用程序的旅程,让我们回顾一下我们学到的一些东西。在三个项目中,我们看到了 Django 如何将代码组织成模型、视图和模板。我们学会了如何使用 Django 的表单类和 Django Rest Framework 的序列化器类进行输入验证。我们审查了安全最佳实践、缓存以及如何发送电子邮件。我们看到了如何将我们的代码部署到 Linux 服务器、Docker 容器和 AWS 云中。

您已经准备好使用 Django 来实现您的想法了!加油!

posted @ 2024-05-20 16:47  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报