Django-和-React-全栈开发-全-

Django 和 React 全栈开发(全)

原文:zh.annas-archive.org/md5/bd742fc2af9d0b64a6f94d7b9da9035f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 Python 或 JavaScript 开始全栈开发可能会令人望而却步,尤其是如果你已经是使用这些语言之一的开发生态中的一员,并希望将第二种语言添加到你的技能集中。如果你是一名正在使用 Django 或 React 的开发者,或者是一名了解 Python 或 JavaScript 的开发者,并希望学习如何从头开始构建具有身份验证、CRUD 操作等功能的全栈应用程序,同时你还在寻找如何使用 Docker 在 AWS 上部署 Web 应用程序的方法,本书涵盖了你需要的一切。

本书将帮助你发现结合两个最受欢迎的框架——React 和 Django——的双重力量的全部潜力实践。我们将构建全栈应用程序,包括后端的 RESTful API 和直观的前端,同时探索这两个框架的高级功能。我们将从头开始构建一个名为 Postagram 的社交媒体网络应用程序,同时涵盖端到端开发的重要概念、技术和最佳实践。

我们将看到 React 框架的动态功能如何用于构建你的前端系统,以及 Django 的 ORM 层如何帮助简化数据库,从而提高构建全栈应用程序的后端开发过程。

在本书结束时,你将能够从头开始独立创建一个动态的全栈应用程序。

本书面向的对象

这本书是为那些熟悉 Django 但不知道如何开始构建全栈应用程序(更确切地说,构建 RESTful API)的 Python 开发者而写的。如果你是一名了解 JavaScript 的前端开发者,并希望学习全栈开发,这本书也会对你很有帮助。如果你是一名经验丰富的全栈开发者,正在使用不同的技术,并希望探索和学习新的技术,这本书也是为你而写的。

本书涵盖的内容

第一章创建 Django 项目,展示了如何创建 Django 项目以及与数据库服务器进行必要的配置。

第二章使用 JWT 进行身份验证和授权,解释了如何使用 JSON Web Tokens 实现身份验证系统以及如何编写自定义权限。

第三章社交媒体帖子管理,展示了如何使用序列化器和视图集实现复杂的 CRUD 操作。

第四章在社交媒体帖子中添加评论,展示了如何使用数据库关系、序列化器和视图集向帖子添加评论。

第五章测试 REST API,介绍了使用 Django 和 Pytest 进行测试。

第六章, 使用 React 创建项目,解释了如何在配置良好的开发环境中创建 React 项目。

第七章, 构建注册和登录表单,解释了如何在全栈应用程序的前端实现认证表单和逻辑。

第八章, 社交媒体帖子,展示了如何在 React 前端实现社交媒体帖子的 CRUD 操作。

第九章, 评论,展示了如何在 React 前端实现社交媒体评论的 CRUD 操作。

第十章, 用户资料,解释了如何在 React 前端实现与资料相关的 CRUD 操作以及如何上传图片。

第十一章, React 组件的有效 UI 测试,向您介绍使用 Jest 和 React Testing Library 进行组件测试。

第十二章, 部署基础 - Git、GitHub 和 AWS,介绍了 DevOps 工具和术语以及如何在 AWS EC2 上直接部署 Django 应用程序。

第十三章, 将 Django 项目 Docker 化,展示了如何使用 Docker 和 Docker Compose 将 Django 应用程序 Docker 化。

第十四章, 在 AWS 上自动化部署,展示了如何使用 GitHub Actions 在 EC2 上部署 Docker 化的应用程序。

第十五章, 在 AWS 上部署我们的 React 应用,演示了如何在 AWS S3 上部署 React 应用程序并使用 GitHub Actions 自动化部署。

第十六章, 性能、优化和安全,向您展示如何使用 webpack 优化应用程序,优化数据库查询,并增强后端安全性。

为了最大限度地利用本书

为了使用本书,您需要在您的机器上安装 Python 3.8+、Node.js 16+和 Docker。本书中的所有代码和示例都是使用 Django 4.1 和 React 18 在 Ubuntu 上测试的。在安装任何 React 或 JavaScript 库时,请确保您有它们文档中提供的最新安装命令(npmyarnpnpm),并检查是否有与本书中使用版本相关的任何重大更改。

本书涵盖的软件/硬件 操作系统要求
Python Windows, macOS, 或 Linux
JavaScript Windows, macOS, 或 Linux
PostgreSQL Windows, macOS, 或 Linux
Django Windows, macOS, 或 Linux
React Windows, macOS, 或 Linux
Docker Windows, macOS, 或 Linux

如果您正在使用本书的数字版,我们建议您自己输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Full-stack-Django-and-React。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一个包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:packt.link/jdEHp

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“一旦安装了包,就在 Django 项目的根目录下创建一个名为pytest.ini的新文件。”

代码块设置如下:

>>> comment = Comment.objects.create(**comment_data)
>>> comment
<Comment: Dingo Dog>
>>> comment.body
'A comment.'

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

ENV = os.environ.get("ENV")
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
   "SECRET_KEY", default="qkl+xdr8aimpf-&x(mi7)dwt^-q77aji#j*d#02-5usa32r9!y"
)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False if ENV == "PROD" else True
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="*").split(",")

任何命令行输入或输出都应如下所示:

pip install drf-nested-routers

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“最后,选择权限选项卡并选择存储桶策略。”

小贴士或重要提示

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送给我们,电子邮件地址为 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将非常感激您能向我们提供位置地址或网站名称。请通过电子邮件发送给我们,电子邮件地址为 copyright@packt.com,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《Full Stack Django and React》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走吗?
你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/9781803242972

  1. 提交你的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他优惠发送到您的电子邮件。

第一部分:技术背景

在本书的这一部分,你将学习如何使用 Django 和 Django REST 构建 REST API。本部分提供了将 Django 连接到 PostgreSQL 数据库、使用 JSON Web Tokens 添加身份验证、创建支持复杂 CRUD 操作的 RESTful 资源以及向 Django 应用程序添加测试所需的知识。我们将特别构建一个名为 Postagram 的社交媒体网络应用程序的后端,该应用程序具有社交媒体应用程序最常见的特点,如帖子管理、评论管理和帖子点赞。

本节包括以下章节:

  • 第一章, 创建 Django 项目

  • 第二章, 使用 JWT 进行身份验证和授权

  • 第三章, 社交媒体帖子管理

  • 第四章, 为社交媒体帖子添加注释

  • 第五章, 测试 REST API

第一章:创建 Django 项目

Django是使用 Python 编写的最著名的后端框架之一,常用于构建简单或复杂的 Web 应用程序。至于React,它是创建反应性和强大用户界面的最广泛使用的 JavaScript 库之一。在本章中,我们将首先关注 Django。

在本章中,我们将简要介绍软件开发,特别是我们将在 Django 和 React 的背景下构建的社交网络 Web 应用程序中的后端开发。我们还将讨论在Python中用于后端开发的常见工具——在这里是 Django。然后,我们将创建一个 Django 项目,并解释 Django 项目最重要的部分。之后,我们将PostgreSQL连接到 Django 项目。

到本章结束时,你将理解软件开发、前端开发和后端开发的概念。你还将学习如何在 Django 中创建项目并启动服务器。

在本章中,我们将涵盖以下主题:

  • 软件开发概述

  • 理解后端开发

  • 什么是 API?

  • 什么是 Django?

  • 设置工作环境

  • 配置数据库

软件开发概述

软件开发是一个复杂的过程,包含许多步骤和许多组件。这些组件确保在构思、指定、设计、编程、文档化和测试应用程序、框架或软件时得到尊重并得到良好的应用。

通常,软件由以下两个组件组成:

  • 后端:这代表用户看不到的部分;它由业务逻辑和从数据库中进行的数据操作组成

  • 前端:这代表提供给用户与整个应用程序交互的界面

前端这个术语指的是用户在屏幕上看到的网站或应用程序的元素,以及他们将与它们交互的元素。例如,所有互联网用户都会在网站上看到 HTML、CSS 和 JavaScript 的组合。是这些前端编程语言将被浏览器解释。

通常,前端由 HTML、CSS、JavaScript 和 jQuery(或其他 UI 库或框架)组成,用于复制设计。设计是由网页设计师创建的,他们使用专门的工具,如 Photoshop 或 Figma,来创建图形模型。

在这里,我们将专注于 Web 开发。Web 开发是软件开发的一部分,专注于构建网站和 Web 应用程序,而 Web 开发的理念依赖于客户端-服务器架构。

客户端-服务器架构代表一个环境,其中运行在客户端机器上的应用程序可以与安装在服务器机器上的其他应用程序通信,这些应用程序提供来自数据库的服务或数据。

在网络上,客户端将只是一个浏览器,用于从服务器请求页面或资源。

这里有一个简单的图表来展示这一点:

图 1.1 – 客户端-服务器架构

图 1.1 – 客户端-服务器架构

现在我们对软件开发,尤其是 Web 开发有了更好的理解,让我们继续讨论其一个组成部分:后端开发。

理解后端开发

后端开发处理现代应用程序的后台部分。大多数时候,它由连接到数据库、管理用户连接以及为 Web 应用程序或API提供动力的代码组成。

后端开发代码的焦点更多地在于业务逻辑。它主要关注应用程序的工作方式以及支持应用程序的功能和逻辑。

例如,让我们讨论一个用于管理书籍的 Web 应用程序。假设该应用程序连接到一个 SQL 数据库。

无论使用什么语言构建应用程序和结构,以下是一些代表业务逻辑的要求,这些要求主要依赖于后端而不是前端:

  • 添加书籍(仅限管理员):这假设客户端(前端)应该能够向使用为后端构建的任何语言构建的 API 发出请求,包含在数据库中创建新条目所需的数据。此操作仅限于管理员。

  • 列出所有书籍:这假设客户端也应该能够向 API 发出请求,并且这个 API 应该以 JSON/XML 格式作为响应发送所有书籍的列表。

只需看一下这两个要求,我们就可以快速理解,前端将只是请求这些操作的界面。然而,后端将(以第一个要求为例)确保传入的请求是可能的(检查权限,例如请求用户是否真的是管理员)以及请求中的数据是有效的——只有在这之后,数据才能安全地注册到数据库中。后端开发者使用 Python、PHP 和 Ruby 等编程语言来设置和配置服务器。这些工具将允许他们存储、处理和修改信息。为了使这些编程语言更加实用,开发者将通过框架如 Symfony、Ruby on Rails、CakePHP 或 CodeIgniter 来改进它们。这些工具将使开发更快、更安全。然后他们必须确保这些工具始终保持最新,并便于维护。

因此,后端开发者负责创建和管理所有对最终用户不可见元素。因此,他们负责网站或应用程序的所有功能。他们还负责创建数据库,这将允许用户提供的信息得到保留。例如,后端开发者将使用数据库来查找客户使用的用于连接的用户名和密码。可以通过学习网络开发或甚至学习 Python 来培养这一职业。

后端开发者的职责

后端通常由三个主要部分组成:

  • 服务器:接收请求的机器或应用程序(NGINX)

  • 应用程序:在服务器上运行的应用程序,它接收请求,验证这些请求,并发送适当的响应

  • 数据库:用于存储数据

因此,后端程序员的职责可能很容易包括编写 API、编写与数据库交互的代码、创建模块或库、处理业务数据和架构,以及更多。

他们还必须做以下事情:

  • 协调并与前端开发者沟通,以高效地将数据传输到应用程序的客户端

  • 与质量保证工程师合作,优化服务器端流程,并通过一些安全检查

  • 当请求数量或用户数量扩展时,优化应用程序

  • 分析项目需求并创建一个简单的结构来处理错误和异常

  • 提出高效的云托管解决方案,并构建 CI/CD 管道

后端架构实际上有助于构建软件行业中消费数据最常见接口之一:应用程序编程接口API)。让我们更深入地了解这个术语。

什么是 API?

在这本书中,我们将主要构建一个 API——那么,什么是 API?

在回答这个问题之前,只需记住,互联网的大部分是由表示状态转移REST)或RESTful API驱动的。API 简化了应用程序或机器之间数据交换的方式。它主要由两个组件组成:

  • 技术规范,它描述了各方之间的数据交换选项,规范以数据交付协议和数据处理的请求形式制定

  • 软件接口(编程代码),它是根据其表示的规范编写的

例如,如果你的应用程序客户端是用 JavaScript 编写的,而服务器端是用 PHP 编写的,你需要创建一个 PHP(因为数据来自数据库)的 Web API,这将帮助你编写用于访问数据的规则和路由。

Web API 相对常见,有不同的规范和协议。API 规范的目标是为了标准化——由于不同的编程语言和不同的 操作系统OSs)——两个或多个 Web 服务之间的交换。例如,你会找到以下内容:

  • 远程过程调用RPC):一种协议,程序可以使用它从网络上另一台计算机上的程序请求服务,而无需了解其细节。这有时被称为函数或子程序调用。

  • 简单对象访问协议SOAP):一种基于 XML 的通信协议,允许应用程序通过 HTTP 交换信息。因此,它允许访问 Web 服务以及 Web 上应用程序的互操作性。SOAP 是一种简单且轻量级的协议,完全依赖于已建立的标准,如 HTTP 和 XML。它是可移植的,因此独立于任何操作系统和计算机类型。SOAP 是一种非专有规范。

  • REST/RESTful:一种用于构建应用程序(Web、内部网或 Web 服务)的架构风格。这是一组需要遵守的约定和最佳实践,而不是一种独立的技术。REST 架构使用 HTTP 协议的原始规范,而不是重新发明一个覆盖层(例如 SOAP 或 XML-RPC 所做的那样):

    • 规则 1:URL 是资源标识符

    • 规则 2:HTTP 动词是操作的标识符

    • 规则 3:HTTP 响应是资源的表示

    • 规则 4:链接是资源之间的关系

    • 规则 5:参数是一个认证令牌

在这本书中,我们将使用 Django 和 Django REST 构建 REST API,因此让我们更好地了解 REST。

理解 REST API

当开发者想要构建一个 API 时,通常会选择 REST。REST 相比于 SOAP 和 RPC 是一个简单的替代方案,因为它使得编写访问资源的逻辑更加容易;这里的资源通过一个唯一的 URL 表示,通过对这个 URL 的一次请求即可获取。

RESTful API 使用 HTTP 请求(或方法)与资源进行交互:

  • GET:API 和网站中最常用的方法。此方法用于从服务器获取指定资源的数据。这个资源通常是一个端点,返回一个对象或对象列表,通常是 JSON 或 XML 格式。

  • POSTPOST 方法是请求服务器进行信息处理的基本方法。这些请求应该激活服务器特定的机制,并导致与其他模块或甚至其他服务器的通信来处理这些数据。因此,两个相同的 POST 请求可能会收到不同甚至语义相反的响应。要处理的数据指定在请求体中。通过页面指定的请求文档是必须处理数据并生成响应的资源。

  • HEAD: HEAD 方法用于查询响应的头部,而无需立即将文件发送给您。这在需要传输大文件时很有用:由于 HEAD 请求,客户端可以先了解文件的大小,然后再决定是否接收文件。

  • OPTIONS: 这是一个诊断方法,主要用于调试等目的,它返回的消息基本上表明了在 Web 服务器上哪些 HTTP 方法是激活的。实际上,如今它很少用于合法目的,但它确实给潜在的攻击者提供了一些帮助——它可以被视为找到另一个漏洞的捷径。

  • DELETEPUT: 这些方法本应允许上传(到服务器)或删除文档,而无需通过文件传输协议(FTP)服务器或类似的服务器。显然,这可能导致文件替换,因此可能导致服务器上非常大的安全漏洞。因此,大多数 Web 服务器都需要对资源或文档进行特殊配置,以处理这些请求。请求中引用的文档是要替换(或创建)的文档,而文档的内容在请求体中。理论上,服务器应禁止或忽略 URL 参数和片段标识符。在实践中,它们通常被传输到处理请求的资源。

  • PATCH: HTTP 请求的 PATCH 方法对资源应用部分更改。

  • TRACE: TRACE 方法可用于跟踪 HTTP 请求从服务器到客户端的路径。

  • CONNECT: 这个方法本应用于请求将服务器用作代理。并非所有服务器都必然实现它们。

一个有趣的好处是,RESTful 系统支持不同的数据格式,如纯文本、HTML、YAML、JSON 和 XML。

如前所述,在这本书中,我们将使用 Django 和 Django REST 构建 REST API。

什么是 Django?

Django 是一个高级 Web 框架,它首次于 2005 年发布。它用 Python 编写,并使用模型-视图-控制器MVC)架构模式。这种模式通常被定义为如下:

  • 模型: 对应所有与数据相关的逻辑。它与数据库深度连接,因为它提供了数据的形状,同时也提供了创建、读取、更新和删除(CRUD)操作的方法和函数。

  • 视图: 处理应用程序的 UI 逻辑。

  • 控制器: 代表模型和视图之间的一个层。大多数时候,控制器解释来自视图的传入请求,操作模型组件提供的数据,并与视图再次交互以渲染最终输出。

在 Django 中,这将被称为模型-视图-模板MVT)架构,其中模板对应于视图,而在这里视图由控制器表示。以下是 MVT 架构的简单表示:

图 1.2 – MVT 架构

图 1.2 – MVT 架构

Django 是一个采用“包含电池”方法的 Web 框架。在开发自定义 Web 应用程序时,Django 提供了加快开发进程所需的工具。它提供了用于常见操作(如数据库操作、HTML 模板、URL 路由、会话管理和安全)的代码和工具。

Django 允许开发者使用所有必要的功能(如应用安全)从头开始构建各种类型的 Web 应用程序(社交网络、新闻网站和维基),从而让开发者能够专注于他们的项目的大部分工作。Django 提供了对常见攻击的保护——跨站脚本、SQL 注入等等。

在这里,我们还将使用Django REST 框架DRF)。它是最成熟、可测试、文档完善且易于扩展的框架,与 Django 结合使用时,将有助于创建强大的 RESTful API。Django 和 DRF 的组合被 Instagram、Mozilla 甚至 Pinterest 等大型公司所采用。

当这个框架与 Django 结合使用时,视图将被路由或端点所取代。我们将在本书的后续部分讨论这个概念——但为什么要用 Django 构建 API?

诚然,传统的 Django 支持 HTML、CSS 和 JavaScript 等客户端语言。这有助于构建由服务器提供服务的用户界面,并且性能始终令人印象深刻。

然而,如果您有多个机器将访问 Django 服务器上的资源呢?如果这些机器运行基于 JavaScript 的应用程序,我们始终可以使用传统的 Django 方式。

如果它是一个移动应用程序呢?如果它是一个用 PHP 编写的服务呢?

这正是 API 真正有用之处。您可以使用任意数量的机器请求您的 API 数据,而不会出现问题,无论这些机器运行的应用程序使用的技术或语言如何。

既然您已经对 Django 有了了解,让我们设置工作环境并在 Django 中创建我们的第一个服务器。

设置工作环境

在开始使用 Django 之前,我们必须确保您现在使用的操作系统下有一个优秀的环境。

首先,请确保您已安装最新版本的 Python。对于本书,我们将使用 Python 3.10。

如果您使用的是 Windows 机器,请访问www.python.org/downloads/的官方下载页面并下载相关版本。

对于 Linux 用户,您可以使用默认的仓库包下载管理器下载。

创建虚拟环境

现在我们已经安装了 Python,我们必须确保已经安装了 virtualenv

python3 -m pip install --user virtualenv

以下为 Windows 用户说明:

py -m pip install --user virtualenv

完成这些后,我们现在可以创建一个虚拟环境——但为什么要这样做呢?

在使用 Python 进行开发时,有两种环境类型:全局环境和本地环境。

如果你只是在终端中随机输入 pip install requests,该包将被安装并可以在全局范围内访问:这意味着可以在你的机器上的任何地方访问。有时,你可能想要隔离工作环境以避免版本冲突。例如,全局上你可能正在使用支持 Django 2.x 版本的 Python 3.5。然而,对于这个项目,你希望使用 Python 3.10 和 Django 的最新版本——这里,4.0。创建一个 virtualenv 环境可以帮助你做到这一点。

现在我们已经安装了 virutalenv,我们可以创建并激活 virtualenv 环境——但在那之前,创建一个名为 django-api 的目录。我们将在这里构建 Python 项目。

以下为 Unix 或 macOS 的说明:

python3 -m venv venv

以下为 Windows 的说明:

py -m venv venv

前面的命令将创建包含已安装 Python 包和必要配置的 venv 目录,以便在激活虚拟环境时访问这些包。下一步是激活虚拟环境。这将帮助我们安装开始工作所需的包。

以下为 Unix 或 macOS 的说明:

source venv/bin/activate

以下为 Windows 的说明:

.\venv\Scripts\activate

太好了!接下来,让我们安装 Django 包。

安装 Django

在 Python 中安装包有两种方式。你可以简单地运行 pip install package_name

或者,你可以将包名及其版本写入一个文本文件中。我将选择后者,但你可以自由选择对你来说适用的任何版本。

只需理解版本之间可能会有一些变化,这可能会影响你的项目。为了与这里将要使用的相似,你也可以使用后面的选项。

太好了——让我们在 django-api 目录的根目录下创建一个名为 requirements.txt 的文件,并添加 Django 包名:

Django==4.0

太好了!现在,运行 pip install -r requirements.txt 来安装 Django。

为了确保一切正常工作,我们将快速创建一个简单的项目。

创建示例项目

要创建一个新的项目,我们将使用 django-admin 命令。它包含我们可以用来在 Django 中创建项目的选项:

django-admin startproject CoreRoot .

不要忘记在这个命令的末尾添加 . 点。这实际上会在当前目录中生成所有文件,而不是创建另一个目录来放置所有文件。

你应该有一个类似以下的结构:

图 1.3 – 文件结构

图 1.3 – 文件结构

在启动服务器之前,让我们运行迁移:

python manage.py migrate

你将得到类似的输出:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, 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 admin.0003_logentry_add_action_flag_choices... 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 auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

迁移只是将数据库模式中对模型所做的更改传播出去的一种方式。由于 Django 也自带一些模型(例如,你可以用于身份验证的 User 模型),我们需要应用这些迁移。当我们编写自己的模型时,我们也会创建迁移文件并将它们迁移。Django 有 对象关系映射ORM),它可以自动为你处理与数据库的交互。

当你对 SQL 和编写自己的查询还不太熟悉时,学习 SQL 和编写自己的查询相当困难且要求很高。这需要很长时间,而且相当令人望而却步。幸运的是,Django 提供了一个系统,让你能够利用 SQL 数据库的好处,而无需编写哪怕一个 SQL 查询!

这种类型的系统被称为 ORM。在这个听起来有些野蛮的名字背后隐藏着一个简单而非常实用的操作。当你你在 Django 应用程序中创建一个模型时,框架会自动在数据库中创建一个合适的表来保存与模型相关的数据。

这里不需要编写 SQL 命令 – 我们将只编写 Python 代码,这些代码将被直接转换为 SQL。然后 python manage.py migrate 将将这些更改应用到数据库中。

现在,运行 python manage.py runserver。你会看到类似的输出,并且你的服务器也会在 localhost:8000 上运行。

只需在你的浏览器中输入这个 URL,你将看到如下内容:

图 1.4 – Django 运行服务器的欢迎页面

图 1.4 – Django 运行服务器的欢迎页面

太好了 – 我们刚刚安装了 Django 并启动了 Django 服务器。让我们谈谈项目的结构。

讨论示例项目

在最后一部分,我们简要地讨论了如何使用 Python 创建 virtualenv 环境。我们还创建了一个 Django 项目并使其运行。

让我们快速谈谈这个项目。

你可能已经注意到了 django-api 目录中的一些文件和目录。好吧,让我们快速谈谈这些:

  • manage.py:这是 Django 为许多不同需求提供的一个实用工具。它将帮助你创建项目和应用程序、运行迁移、启动服务器等等。

  • CoreRoot:这是使用 django-admin 命令创建的项目名称。它包含以下文件等:

    • urls.py:这个文件包含了将用于访问项目中资源的所有 URL:

      from django.contrib import admin
      
      from django.urls import path
      
      urlpatterns = [
      
           path('admin/', admin.site.urls),
      
      ]
      
    • wsgi.py:这个文件基本上用于部署,但在 Django 中也用作默认的开发环境。

    • asgi.py:Django 还支持以 ASGI 应用程序运行异步代码。

    • settings.py:这个文件包含了你 Django 项目的所有配置。你可以找到 SECRET_KEYINSTALLED_APPS 列表、ALLOWED_HOST 等等。

现在你已经熟悉了 Django 项目的结构,让我们看看如何配置项目以连接到数据库。

配置数据库

默认情况下,Django 使用 sqlite3 作为数据库,这是一个进程内库,实现了快速自包含、零配置、无服务器、事务性的 SQL 数据库引擎。它非常紧凑且易于使用和设置。如果你希望快速保存数据或进行测试,它非常理想。然而,它也有一些缺点。

首先,它没有多用户功能,这意味着它缺乏细粒度的访问控制和一些安全功能。这是由于 SQLite 直接读取和写入普通磁盘文件的事实。

例如,在我们的项目中,运行迁移后,你会注意到新文件的创建,名为 db.sqlite3。嗯,实际上这就是我们的数据库。

我们将用更强大的 SMDB,称为 Postgres 来替换它。

Postgres 配置

PostgreSQL 是世界上最先进的企业级开源数据库管理系统之一,由 PostgreSQL 全球开发组开发和维护。它是一个功能强大且高度可扩展的对象关系型 SQL 数据库系统,具有以下有趣的功能:

  • 用户定义的类型

  • 表继承

  • 异步复制

  • 多用户功能

这些是在数据库中寻找的功能,尤其是在开发或生产环境中工作时。

根据你的操作系统,你可以在 www.postgresql.org/download/ 下载 Postgres 版本。在这本书中,我们使用的是 PostgreSQL 14。

一旦完成,我们将为 Python 安装一个 PostgreSQL 适配器,psycopg

pip install psycopg2-binary

不要忘记将其添加到 requirements.txt 文件中:

Django==4.0
psycopg2_binary==2.9.2

太好了——现在我们已经安装了适配器,让我们快速创建我们将用于此项目的数据库。

为了做到这一点,我们需要在终端中以 Postgres 用户身份连接,然后访问 psql 终端。在那个终端中,我们可以输入 SQL 命令。

对于 Linux 用户,你可以按以下方式登录:

sudo su postgres

然后,输入 psql

太好了——让我们创建数据库:

CREATE DATABASE coredb;

要连接到数据库,我们需要 USER 和密码:

CREATE USER core WITH PASSWORD 'wCh29&HE&T83';

总是使用强密码是一个好习惯。你可以在 https://passwordsgenerator.net/ 生成强密码——接下来的步骤是授予新用户对数据库的访问权限:

GRANT ALL PRIVILEGES ON DATABASE coredb TO core;

我们几乎完成了。我们还需要确保这个用户可以创建数据库。当我们能够运行测试时,这将非常有帮助。要运行测试,Django 将配置完整的环境,但也会使用数据库:

 ALTER USER core CREATEDB;

这样,我们就完成了数据库的创建。让我们将此数据库连接到我们的 Django 项目。

连接数据库

将数据库连接到 Django 需要进行一些配置。然后,我们必须打开 settings.py 文件,查找数据库配置,然后进行修改。

settings.py 文件中,你会找到一个类似的行:

# Database
# https://docs.djangoproject.com/en/4.0/ref        /settings/#databases
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

太好了——正如你所见,项目仍在 SQLite3 引擎上运行。

删除此内容,并用以下内容替换:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': coredb,
        'USER': 'core',
           'PASSWORD': 'wCh29&HE&T83',
        'HOST': 'localhost',
        'PORT': '5342',
    }
}

我们刚刚修改了数据库引擎,同时也填写了诸如数据库名称、用户、密码、主机和端口号等信息。

MySQL 数据库的ENGINE键可能不同。除此之外,还有一些额外的键,例如USERPASSWORDHOSTPORT

  • NAME:此键存储您的 MySQL 数据库名称

  • USER:此键存储 MySQL 数据库将连接到的 MySQL 账户的用户名

  • PASSWORD:此键存储此 MySQL 账户的密码

  • HOST:此键存储您的 MySQL 数据库托管在其上的 IP 地址

  • PORT:此键存储您的 MySQL 数据库托管在其上的端口号

配置已完成。让我们运行迁移并查看一切是否正常工作:

python manage.py migrate

您将在终端中获得类似的输出:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, 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 admin.0003_logentry_add_action_flag_choices... 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 auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

太好了!我们刚刚用 PostgreSQL 配置了 Django。

安装 HTTP 请求客户端

当作为后端开发者开发 API 时,拥有一个 API 客户端来测试您的 API 并确保其按预期行为是一个好习惯。API 客户端是发送 HTTP 请求到 API 的包或库。绝大多数支持 SSL 检查、身份验证和头部修改等功能。在本章中,我们将使用 Insomnia。它轻量级,易于使用和定制。

要下载适合您操作系统的 Insomnia 版本,请访问以下页面:insomnia.rest/download

摘要

在本章中,我们探索了后端开发的世界,以阐明后端开发者的角色和职责。我们还讨论了 API,主要是 REST API,这些 API 将在本书中构建。我们还简要介绍了 Django,该框架使用的 MVT 架构,以及将 PostgreSQL 数据库连接到 Django 项目。

在下一章中,我们将通过创建我们的第一个模型、测试和端点来深入了解 Django。

问题

  1. 什么是 REST API?

  2. 什么是 Django?

  3. 如何创建 Django 项目?

  4. 什么是迁移?

  5. Python 中的虚拟环境是什么?

第二章:使用 JWT 进行认证和授权

在本章中,我们将更深入地探讨 Django 及其架构。我们将使用模型序列化器视图集来创建一个可以接收 HTTP 请求并返回响应的 API。这将通过构建一个使用JSON Web Tokens(JWT)的认证和授权系统来实现,以允许用户创建账户、登录和登出。

到本章结束时,您将能够创建 Django 模型、编写 Django 序列化器和验证、编写视图集来处理 API 请求、通过 Django REST 路由器公开视图集、基于 JWT 创建认证和授权系统,并理解 JWT 是什么以及它如何帮助进行认证和权限管理。

在本章中,我们将介绍以下主题:

  • 理解 JWT

  • 组织项目

  • 创建用户模型

  • 编写用户注册功能

  • 添加登录功能

  • 刷新逻辑

技术要求

对于本章内容,您需要在您的机器上安装 Insomnia 来向我们将要构建的 API 发送请求。

您也可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap2找到本章的代码。

理解 JWT

在编写认证功能之前,让我们解释一下 JWT 是什么。如前所述,JWT代表JSON Web Token。它是网络应用程序中最常用的认证方式之一,同时也帮助进行授权和信息交换。

根据 RFC 7519,JWT 是一个 JSON 对象,被定义为在双方之间安全传输信息的方式。JWT 传输的信息是数字签名的,因此可以被验证和信任。

JWT 包含三个部分——一个头部(x)、一个负载(y)和一个签名(z),它们由点分隔:

xxxxx.yyyyy.zzzzz
  • 头部

JWT 的头部由两部分组成:令牌类型和使用的签名算法。签名算法用于确保消息的真实性且未被篡改。

下面是一个头部的示例:

{
    "alg": "RSA",
    "typ": "JWT"
}

签名算法是用于为您的应用程序或 API 签发令牌的算法。

  • 负载

负载是包含声明的第二部分。根据官方 JWT 文档(jwt.io/introduction),声明是关于一个实体(通常是用户)及其附加数据的陈述。

下面是一个负载的示例:

{
  "id": "d1397699-f37b-4de0-8e00-948fa8e9bf2c",
  "name": "John Doe",
  "admin": true
}

在前面的示例中,我们有三个声明:用户的 ID、用户的名字,以及一个表示用户类型的布尔值。

  • 签名

JWT 的签名是由编码的头部、编码的负载加上一个密钥,以及头部中指定的算法组合并签名而成的。

例如,可以使用 RSA 算法以下方式创建签名:

RSA(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

签名的作用是跟踪信息是否被更改。

但 JWT 实际上是如何在身份验证中使用的呢?

理解 JWT 在身份验证中的应用

每次用户成功登录时,都会创建并返回一个 JWT。JWT 将作为用于访问受保护资源的凭证。由于可以在 JWT 中存储数据,这使得它容易受到攻击。这就是为什么在创建 JWT 时应该指定一个过期时间。

在这本书中,我们将以两种方式使用 JWT。为了简单起见,我们将有两种类型的令牌:

  • 访问令牌:用于访问资源和处理授权

  • 刷新令牌:用于获取新的访问令牌

但为什么使用两个令牌呢?正如我们之前所述,JWT 是在用户登录时生成的。此外,用于访问资源的 JWT 应该有很短的生命周期。这意味着 JWT 过期后,用户必须一次又一次地登录——而且没有用户希望登录页面每 5 分钟就出现一次。

这就是刷新令牌有用的地方。它将包含验证用户和生成新访问令牌所需的基本信息。

现在我们已经了解了 JWT 的目的,让我们在创建用户模型的同时,更多地了解 Django 中的模型以及它们解决的问题。

组织项目

当使用 Django 工作时,你需要创建许多应用程序来处理项目的不同部分。例如,你可以为身份验证有一个不同的应用程序,为支付或文章有另一个应用程序。为了有一个干净且组织良好的项目,我们可以创建一个 Django 应用程序,它将包含我们将为这本书创建的所有应用程序。

在项目的根目录下,运行以下命令:

django-admin startapp core

将创建一个新的应用程序。删除此应用程序中除apps.py文件和__init__.py文件之外的所有文件。在apps.py内部,添加以下行:

core/apps.py

from django.apps import AppConfig
class CoreConfig(AppConfig):
   default_auto_field = 'django.db.models.BigAutoField'
   name = 'core'
   label = 'core'

在项目的setting.py文件中注册应用程序:

CoreRoot/settings.py

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

INSTALLED_APPS是 Django 设置配置,它是一个项目内 Django 应用程序的列表。

我们现在可以自信地创建用户应用程序并编写我们的第一个模型。

创建用户模型

除非你正在创建一个简单的 Web 应用程序,否则很难避免与数据库交互的必要性,尤其是拥有需要用户注册或登录才能使用你的 Web 应用程序的账户功能。

在讨论账户功能之前,让我们更多地了解 Django 模型以及它们解决的问题。

Django 模型是什么?

如果你需要将你的应用程序连接到数据库,尤其是SQL,首先想到的假设是你将不得不直接通过 SQL 查询与数据库进行交互——如果这是真的,那可能很有趣,但并非对每个人来说都是如此;一些开发者可能会发现 SQL 很复杂。你不再专注于用你自己的语言编写应用程序逻辑。一些任务可能会变得重复,例如编写 SQL 脚本来创建表、从数据库中获取条目或插入或更新数据。

正如你所看到的,随着代码库的不断发展,维护代码库中的简单和复杂 SQL 查询变得越来越困难。如果你正在使用多个数据库,这个问题会更加严重,这需要你学习许多 SQL 语言。例如,有许多 SQL 数据库,每个数据库都以自己的方式实现 SQL。

幸运的是,在 Django 中,这个问题通过使用 Django 模型来访问数据库得到了解决。这并不意味着你不需要编写 SQL 查询:只是说,除非你想这么做,否则你不必使用 SQL。

Django 模型为底层数据库提供了 对象关系映射ORM)。ORM 是一个工具,通过提供对象和数据库之间简单的映射来简化数据库编程。然后,你不必一定知道数据库结构或编写复杂的 SQL 查询来操作或从数据库中检索数据。

例如,在 SQL 中创建一个表需要编写一个长的 SQL 查询。在 Python 中做这件事只需要编写一个继承自 django.db 包的类(图 2.1):

图 2.1 – Django ORM 与 SQL 查询的比较

图 2.1 – Django ORM 与 SQL 查询的比较

在前面的图中,你可以看到 SQL 语句,它需要一些语法知识,以及字段和选项。Django ORM 的第二个代码块做了完全相同的事情,但以更 Pythonic 和更简洁的方式。

使用 Django 编写模型具有几个优点:

  • 简洁性:在 Python 中编写查询可能不如在 SQL 中编写清晰,但它更不容易出错,也更高效,因为你不需要在尝试理解代码之前控制你正在使用的数据库类型。

  • 一致性:SQL 在不同的数据库之间是不一致的。使用 Django 模型创建了一个抽象层,有助于你专注于最重要的任务。

  • 跟踪:与 Django 模型一起工作,跟踪数据库设计变更甚至更容易。这是通过阅读用 Python 编写的迁移文件来完成的。我们将在下一章中进一步讨论这个问题。

注意,你还可以访问模型管理器。Django 管理器是一个类,它作为一个接口,通过它 Django 模型与数据库交互。每个 Django 模型默认继承自 models.Manager 类,该类提供了在数据库表上执行 创建、读取、更新和删除CRUD)操作所必需的方法。

现在我们对 Django 模型有了更好的理解,让我们在这个项目中创建第一个模型,即 User 模型。通过使用我们的第一个模型,我们还将学习如何使用 Django ORM 的基本方法来执行 CRUD 操作。

编写用户模型

在前面的部分中,我们看到了模型是如何作为一个类来表示的,以及它基本上可以创建为数据库中的表。

谈到User模型,Django 自带一个预构建的User模型类,你可以用它来进行基本认证或会话。它实际上提供了一个认证功能,你可以用它快速为你的项目添加认证和授权。

虽然这对于大多数用例来说都很棒,但它也有其局限性。例如,在这本书中,我们正在构建一个社交媒体网络应用程序。这个应用程序中的用户将有一些简介或甚至一个头像。为什么不能也有一个电话号码用于双因素****认证(2FA)呢?

实际上,Django 的User模型并没有包含这些字段。这意味着我们需要扩展它并拥有自己的用户模型。这也意味着我们还需要为创建用户和超级用户添加自定义方法到管理器中。这将加快编码过程。在 Django 中,超级用户是具有管理员权限的用户。

在创建模型之前,我们实际上需要一个应用程序,并注册它。Django 应用程序是 Django 项目的子模块。它是一个 Python 包,旨在在 Django 项目中工作并遵循 Django 约定,例如包含文件或子模块,如modelstestsurlsviews

创建用户应用程序

要在这个项目中启动一个新的应用程序,请运行以下命令:

cd core && django-admin startapp user

这将创建一个包含新文件的新包(目录)。以下是目录结构:

├── admin.py
├── apps.py
├── __init__.py
├── migrations
│   └── __init__.py
├── models.py
├── tests.py
└── views.py

现在,我们可以自信地开始编写User模型。以下是我们在数据库中想要拥有的User表的结构:

图 2.2 – 用户表结构

图 2.2 – 用户表结构

下面是关于User表结构的代码:

core/user/models.py

import uuid
from django.contrib.auth.models import AbstractBaseUser,
    BaseUserManager, PermissionsMixin
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.http import Http404
class User(AbstractBaseUser, PermissionsMixin):
   public_id = models.UUIDField(db_index=True, unique=True,
       default=uuid.uuid4, editable=False)
   username = models.CharField(db_index=True,
       max_length=255, unique=True)
   first_name = models.CharField(max_length=255)
   last_name = models.CharField(max_length=255)
   email = models.EmailField(db_index=True, unique=True)
   is_active = models.BooleanField(default=True)
   is_superuser = models.BooleanField(default=False)
   created = models.DateTimeField(auto_now=True)
   updated = models.DateTimeField(auto_now_add=True)
   USERNAME_FIELD = 'email'
   REQUIRED_FIELDS = ['username']
   objects = UserManager()
   def __str__(self):
       return f"{self.email}"
   @property
   def name(self):
       return f"{self.first_name} {self.last_name}"

Django 的models模块提供了一些字段工具,可以用来编写字段并添加一些规则。例如,CharField代表在User表中创建的字段类型,类似于BooleanFieldEmailField也是CharField,但被重新编写以验证传递给此字段的电子邮件值。

我们还将EMAIL_FIELD设置为电子邮件,将USERNAME_FIELD设置为用户名。这将帮助我们有两个登录字段。用户名可以是用户的实际用户名,也可以是用于注册的电子邮件地址。

我们还有像name这样的方法,这基本上是一个模型属性。然后,它可以在User对象的任何地方访问,例如user.name。我们还在重新编写__str__方法,以便返回一个可以帮助我们快速识别User对象的字符串。

创建用户和超级用户

接下来,让我们编写UserManager,这样我们就可以有创建用户和超级用户的方法:

core/user/models.py

class UserManager(BaseUserManager):
   def get_object_by_public_id(self, public_id):
       try:
           instance = self.get(public_id=public_id)
           return instance
       except (ObjectDoesNotExist, ValueError, TypeError):
           return Http404
   def create_user(self, username, email, password=None,
        **kwargs):
       """Create and return a `User` with an email, phone
           number, username and password."""
       if username is None:
           raise TypeError('Users must have a username.')
       if email is None:
           raise TypeError('Users must have an email.')
       if password is None:
           raise TypeError('User must have an email.')
       user = self.model(username=username,
           email=self.normalize_email(email), **kwargs)
       user.set_password(password)
       user.save(using=self._db)
       return user
   def create_superuser(self, username, email, password,
       **kwargs):
       """
       Create and return a `User` with superuser (admin)
           permissions.
       """
       if password is None:
           raise TypeError('Superusers must have a
           password.')
       if email is None:
           raise TypeError('Superusers must have an
               email.')
       if username is None:
           raise TypeError('Superusers must have an
           username.')
       user = self.create_user(username, email, password,
           **kwargs)
       user.is_superuser = True
       user.is_staff = True
       user.save(using=self._db)
       return user

对于create_user方法,我们基本上确保字段如passwordemailusernamefirst_namelast_name不是None。如果一切正常,我们可以自信地调用模型,设置密码,并将用户保存到表中。

这是通过使用save()方法完成的。

create_superuser也遵循create_user方法的操作——这是非常正常的,因为毕竟,超级用户只是一个具有管理员权限的用户,并且is_superuseris_staff字段也设置为True。一旦完成,我们将新的User对象保存到数据库中并返回用户。

看一下save方法,它将User对象所做的更改提交到数据库。

模型已编写,现在我们需要运行迁移以在数据库中创建表。

运行迁移并测试模型

在运行迁移之前,我们需要在CoreRoot/settings.py中的INSTALLED_APPS中注册用户应用程序。

首先,让我们重写用户的apps.py文件。它包含 Django 将用于定位应用程序的应用程序配置。我们还要为应用程序添加一个标签:

core/user/apps.py

from django.apps import AppConfig
class UserConfig(AppConfig):
   default_auto_field = 'django.db.models.BigAutoField'
   name = 'core.user'
   label = 'core_user'
Let's register the application now:
   'core',
   'core.user'
]

让我们现在在INSTALLED_APPS设置中注册应用程序:

CoreRoot/settings.py

...
   'core',
   'core.user'
]

我们还需要告诉 Django 使用这个User模型作为认证用户模型。在settings.py文件中,添加以下行:

CoreRoot/settings.py

AUTH_USER_MODEL = 'core_user.User'

太好了——我们现在可以为用户应用程序创建第一个迁移:

python manage.py makemigrations

您将得到类似的输出:

Migrations for 'core_user':
  core/user/migrations/0001_initial.py
    - Create model User

让我们将此修改迁移到数据库中:

python manage.py migrate

表已创建在数据库中。让我们使用 Django shell 来稍微玩一下新创建的模型:

python manage.py shell

让我们导入模型并添加一个包含创建用户所需数据的字典:

Python 3.10.1 (main, Dec 21 2021, 17:46:38) [GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from core.user.models import User
>>> data_user = {
... "email": "testuser@yopmail.com",
... "username": "john-doe",
... "password": "12345",
... "first_name": "John",
... "last_name": "Doe"
... }
>>> user =  User.objects.create_user(**data_user)
The user is created in the database. Let's access some properties of the user object.
>>> user.name
'John Doe'
>>> user.email
'testuser@yopmail.com'
>>> user.password
'pbkdf2_sha256$320000$NxM7JZ0cQ0OtDzCVusgvV7$fM1WZp7QhHC3QEajnb Bjo5rBPKO+Q8ONhDFkCV/gwcI='

太好了——我们刚刚编写了模型并创建了第一个用户。然而,网络浏览器不会直接从我们的数据库中读取用户数据——更糟糕的是,我们正在处理一个 Python 原生对象,而大多数浏览器或客户端在向我们的服务器发出请求时主要支持 JSON 或 XML。一个想法是使用json库,但我们正在处理一个复杂的数据结构;复杂的数据结构可以很容易地通过序列化器处理。

让我们在下一节编写序列化器。

编写 UserSerializer

序列化器允许我们将复杂的 Django 数据结构,如QuerySet或模型实例,转换为 Python 原生对象,这些对象可以轻松地转换为 JSON 或 XML 格式。然而,序列化器也可以将 JSON 或 XML 序列化为原生 Python。您可以使用serializers包来编写序列化器,并在使用此序列化器对端点进行 API 调用时进行验证。让我们先安装 DRF 包并进行一些配置:

pip install djangorestframework django-filter

不要忘记将以下内容添加到requirements.txt文件中:

requirements.txt

Django==4.0.1
psycopg2-binary==2.9.3
djangorestframework==3.13.1
django-filter==21.1

我们还添加了django-filter以支持数据过滤。让我们将rest_framework添加到INSTALLED_APPS设置中:

CoreRoot/settings.py

INSTALLED_APPS = [
    ...
    'rest_framework',
]

core/user目录中,创建一个名为serializers.py的文件。此文件将包含UserSerializer类:

core/user/serializers.py

from rest_framework import serializers
from core.user.models import User
class UserSerializer(serializers.ModelSerializer):
   id = serializers.UUIDField(source='public_id',
       read_only=True, format='hex')
   created = serializers.DateTimeField(read_only=True)
   updated = serializers.DateTimeField(read_only=True)
   class Meta:
       model = User
       fields = ['id', 'username', 'first_name',
           'last_name', 'bio', 'avatar', 'email',
           'is_active', 'created', 'updated']
       read_only_field = ['is_active']

UserSerializer类继承自serializers.ModelSerializer类。这是一个继承自serializers.Serializer类的类,但它具有对支持模型的深度集成。它将自动将模型的字段与正确的验证匹配。

例如,我们已声明电子邮件是唯一的。那么,每次有人注册并输入数据库中已存在的电子邮件地址时,他们都会收到关于此的错误消息。

fields属性包含所有可读或可写的字段。然后,我们还有只读字段。这意味着它们不能被修改,这样当然更好。为什么给外部用户修改createdupdatedid字段的可能性呢?

现在有了UserSerializer,我们可以编写viewset了。

编写 UserViewset

如我们所知,Django 的核心是基于模型-视图-模板MVT)架构。模型与视图(或控制器)通信,模板显示响应或将请求重定向到视图。

然而,当 Django 与 DRF 结合使用时,模型可以直接连接到视图。但是,作为良好的实践,在模型和视图集之间使用序列化器。这确实有助于验证,也进行了一些重要的检查。

那么,什么是视图集呢?DRF 提供了一个名为APIView的类,许多 DRF 的类都从这个类继承以执行 CRUD 操作。因此,视图集就是一个基于类的视图,它可以处理所有基本的 HTTP 请求——GETPOSTPUTDELETEPATCH——而不需要在这里硬编码任何 CRUD 逻辑。

对于viewset用户,我们只允许PATCHGET方法。以下是端点将看起来像什么:

方法 URL 结果
GET /api/user/ 列出所有用户
GET /api/user/user_pk/ 获取特定用户
PATCH /api/user/user_pk/ 修改用户

表 1.1 – 端点

让我们编写视图集。在user目录中,将view文件重命名为viewsets.py并添加以下内容:

core/user/viewsets.py

from rest_framework.permissions import AllowAny
from rest_framework import viewsets
from core.user.serializers import UserSerializer
from core.user.models import User
class UserViewSet(viewsets.ModelViewSet):
   http_method_names = ('patch', 'get')
   permission_classes = (AllowAny,)
   serializer_class = UserSerializer
   def get_queryset(self):
       if self.request.user.is_superuser:
           return User.objects.all()
       return User.objects.exclude(is_superuser=True)
   def get_object(self):
    obj =
    User.objects.get_object_by_public_id(self.kwargs['pk'])
       self.check_object_permissions(self.request, obj)
       return obj

这里允许的方法只有GETPUT。我们还设置了serializer_classpermission_classesAllowAny,这意味着任何人都可以访问这些视图集。我们还重写了两个方法:

  • get_queryset:这个方法由视图集用来获取所有用户的列表。当使用GET请求访问/user/时,这个方法会被调用。

  • get_object:这个方法由视图集用来获取一个用户。当对/user/id/端点进行GETPUT请求时,这个方法会被调用。

我们在那里有User视图集——但是还没有端点来使其工作。好吧,现在让我们添加一个路由器。

添加路由器

路由器允许您快速声明给定控制器所有常见的路由;下面的代码片段显示了一个我们将添加路由器的视图集。

core 项目的根目录下创建一个名为 routers.py 的文件。

现在让我们添加代码:

core/routers.py

from rest_framework import routers
from core.user.viewsets import UserViewSet
router = routers.SimpleRouter()
# ##################################################################### #
# ################### USER                       ###################### #
# ##################################################################### #
router.register(r'user', UserViewSet, basename='user')
urlpatterns = [
   *router.urls,
]

为视图集注册路由时,register() 方法需要两个参数:

  • 前缀:表示端点的名称,基本上

  • 视图集:仅表示一个有效的视图集类

basename 参数是可选的,但使用它是良好的实践,因为它有助于可读性,并且也有助于 Django 进行 URL 注册。

路由器现在已添加;我们可以使用 Insomnia 向 API 发送一些请求。

重要提示

Insomnia 是一个 REST 客户端工具,用于向 RESTful API 发送请求。使用 Insomnia,您可以优雅地管理和创建请求。它提供对 cookie 管理、环境变量、代码生成和身份验证的支持。

在做之前,请确保服务器正在运行:

python manage.py runserver

让我们向 http://127.0.0.1:8000/api/user/ 发送一个 GET 请求。查看下面的截图,并确保使用相同的 URL – 或者你可以将 127.0.0.1 替换为 localhost --,在 发送 按钮旁边。

图 2.3 – 列出所有用户

图 2.3 – 列出所有用户

如您所见,我们创建了一个用户列表。现在,让我们也使用此 URL 发送一个 GET 请求来检索第一个用户:/api/user/<id>/

图 2.4 – 获取用户

图 2.4 – 获取用户

我们现在有一个 User 对象。此端点还允许 PATCH 请求。让我们将此用户的 last_name 值设置为 Hey。将请求类型更改为 PATCH 并添加一个 JSON 主体。

图 2.5 – 无权限修改用户

图 2.5 – 无权限修改用户

虽然它正在工作,但实际上这是一个非常糟糕的场景。我们不能让用户修改其他用户的名称或数据。一个解决方案是更改 UserViewSet 类中的 permission_classes 属性上的权限。

core/user/viewsets.py

from rest_framework.permissions import IsAuthenticated
...
class UserViewSet(viewsets.ModelViewSet):
   http_method_names = ('patch', 'get')
   permission_classes = (IsAuthenticated,)
   serializer_class = UserSerializer
...

让我们再次尝试 PATCH 请求。

图 2.6 – 无权限修改用户

图 2.6 – 无权限修改用户

我们通常会有一个 401 状态码,这是身份验证问题的指示。基本上,这意味着应该提供一个身份验证头。有关与用户交互的权限,我们还有更多要添加的内容,但让我们在后面的章节中讨论这个问题。

太好了。现在我们已经完成了用户应用程序,我们可以自信地继续向项目中添加登录和注册功能。

编写用户注册功能

在访问受保护的数据之前,用户需要进行身份验证。这假设存在一个注册系统来创建账户和凭证。

为了使事情更简单,如果用户注册成功,我们将提供凭证,这里是 JWT,这样用户就不需要再次登录来开始会话——这是用户体验的胜利。

首先,让我们安装一个将为我们处理 JWT 认证的包。djangorestframework-simplejwt包是 DRF 的 JWT 认证插件:

pip install djangorestframework-simplejwt

该包涵盖了 JWT 最常见的使用场景,在此案例中,它简化了访问令牌和刷新令牌的创建与管理。在使用此包之前,需要在settings.py文件中进行一些配置。我们需要在INSTALLED_APPS中注册应用,并在REST_FRAMEWORK字典中指定DEFAULT_AUTHENTICATION_CLASSES

CoreRoot/settings.py

   …
   # external packages apps
   'rest_framework',
   'rest_framework_simplejwt',
   'core',
   'core.user'
]
...
REST_FRAMEWORK = {
   'DEFAULT_AUTHENTICATION_CLASSES': (
       'rest_framework_simplejwt.authentication
           .JWTAuthentication',
   ),
   'DEFAULT_FILTER_BACKENDS':
     ['django_filters.rest_framework.DjangoFilterBackend'],
}

首先,我们需要编写一个注册序列化器,但在那之前,让我们在core应用中创建一个名为auth的新应用:

cd core && django-admin startapp auth

它将包含有关登录、注册、注销以及更多逻辑的所有逻辑。

正如我们之前为用户应用所做的那样,让我们重写apps.py文件,并在INSTALLED_APPS设置中注册应用:

core/auth/apps.py

from django.apps import AppConfig
class AuthConfig(AppConfig):
   default_auto_field = 'django.db.models.BigAutoField'
   name = 'core.auth'
   label = 'core_auth'
And adding the new application to INSTALLED_APPS:
...
'core',
   'core.user',
   'core.auth'
]
...

auth目录中删除admin.pymodels.py文件,因为我们不会使用它们。对于注册和登录,我们将有许多序列化器和视图集,所以让我们相应地组织代码。创建一个名为serializers的 Python 包和另一个名为viewsets的包。确保这些新目录都有一个__init__.py文件。这是你的auth应用树应该看起来像这样:

├── apps.py
├── __init__.py
├── migrations
│   ├── __init__.py
├── serializers
│   └── __init__.py
├── tests.py
├── viewsets
│   └── __init__.py
└── views.py

serializers目录内,创建一个名为register.py的文件。它将包含RegisterSerializer的代码,这是注册序列化器类的名称:

core/auth/serializers/register.py

from rest_framework import serializers
from core.user.serializers import UserSerializer
from core.user.models import User
class RegisterSerializer(UserSerializer):
   """
   Registration serializer for requests and user creation
   """
   # Making sure the password is at least 8 characters
       long, and no longer than 128 and can't be read
   # by the user
   password = serializers.CharField(max_length=128,
       min_length=8, write_only=True, required=True)
   class Meta:
       model = User
       # List of all the fields that can be included in a
           request or a response
       fields = ['id', 'bio', 'avatar', 'email',
           'username', 'first_name', 'last_name',
           'password']
   def create(self, validated_data):
       # Use the `create_user` method we wrote earlier for
           the UserManager to create a new user.
       return User.objects.create_user(**validated_data)

如您所见,RegisterSerializerUserSerializer的子类。这非常有帮助,因为我们不需要再次重写字段。

在这里,我们不需要重新验证诸如emailpassword之类的字段。因为我们声明了这些字段并附加了一些条件,Django 将自动处理它们的验证。

接下来,我们可以在register.py文件中添加视图集并注册它:

core/auth/viewsets/register.py

from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken
from core.auth.serializers import RegisterSerializer
class RegisterViewSet(ViewSet):
   serializer_class = RegisterSerializer
   permission_classes = (AllowAny,)
   http_method_names = ['post']
   def create(self, request, *args, **kwargs):
       serializer =
           self.serializer_class(data=request.data)
       serializer.is_valid(raise_exception=True)
       user = serializer.save()
       refresh = RefreshToken.for_user(user)
       res = {
           "refresh": str(refresh),
           "access": str(refresh.access_token),
       }
       return Response({
           "user": serializer.data,
           "refresh": res["refresh"],
           "token": res["access"]
       }, status=status.HTTP_201_CREATED)

这里没有真正的新内容——我们正在使用ViewSet类的属性。我们还重写了create方法,在响应体中添加访问和刷新令牌。djangorestframework-simplejwt包提供了我们可以直接生成令牌的实用工具。这就是RefreshToken.for_user(user)所做的事情。

最后一步——让我们在routers.py文件中注册视图集:

core/routers.py

 ...
# ##################################################################### #
# ################### AUTH                       ###################### #
# ##################################################################### #
router.register(r'auth/register', RegisterViewSet,
    basename='auth-register')
...

太好了!让我们用 Insomnia 测试新的端点。在此项目的请求集合中创建一个新的POST请求。URL 如下:localhost:8000/api/auth/register/

作为请求的主体,您可以传递以下内容:

{
    "username": "mouse21",
    "first_name": "Mickey",
    "last_name": "Mouse",
    "password": "12345678",
    "email": "mouse@yopmail.com"
}

然后,发送请求。你应该得到一个类似于*图 2**.6 中所示,带有201 HTTP状态的响应:

图 2.7 – 注册用户

图 2.7 – 注册用户

让我们看看如果我们尝试使用相同的电子邮件和用户名创建用户会发生什么。会触发400错误。

图 2.8 – 使用相同的电子邮件和用户名注册用户

图 2.8 – 使用相同的电子邮件和用户名注册用户

太好了。我们现在可以确信端点的行为符合我们的期望。下一步将是添加登录端点,按照相同的流程:编写序列化器和视图集,然后注册路由。

添加登录功能

登录功能将需要电子邮件或用户名和密码。使用提供名为TokenObtainPairSerializer的序列化器的djangorestframework-simplejwt包,我们将编写一个序列化器来检查用户认证,并返回包含访问和刷新令牌的响应。为此,我们将重写TokenObtainPairSerializer类中的validate方法。在core/auth/serializers目录内,创建一个名为login.py的新文件(此文件将包含LoginSerializer,它是TokenObtainPairSerializer的子类):

core/auth/serializers/login.py

from rest_framework_simplejwt.serializers import
  TokenObtainPairSerializer
from rest_framework_simplejwt.settings import api_settings
from django.contrib.auth.models import update_last_login
from core.user.serializers import UserSerializer
class LoginSerializer(TokenObtainPairSerializer):
   def validate(self, attrs):
       data = super().validate(attrs)
       refresh = self.get_token(self.user)
       data['user'] = UserSerializer(self.user).data
       data['refresh'] = str(refresh)
       data['access'] = str(refresh.access_token)
       if api_settings.UPDATE_LAST_LOGIN:
           update_last_login(None, self.user)
       return data

我们将validate方法从TokenObtainPairSerializer类中提取出来,以适应我们的需求。这就是为什么super在这里很有帮助。它是 Python 中的一个内置方法,返回一个临时对象,可以用来访问基类的类方法。

然后,我们使用user来检索访问和刷新令牌。一旦序列化器编写完成,不要忘记将其导入到__init__.py文件中:

core/auth/serializers/init.py

from .register import RegisterSerializer
from .login import LoginSerializer

下一步是添加视图集。我们将把这个视图集命名为LoginViewset。由于我们在这里不是直接与模型交互,我们只需使用viewsets.ViewSet类:

core/auth/viewsets/login.py

from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework_simplejwt.exceptions import TokenError,
    InvalidToken
from core.auth.serializers import LoginSerializer
class LoginViewSet(ViewSet):
   serializer_class = LoginSerializer
   permission_classes = (AllowAny,)
   http_method_names = ['post']
   def create(self, request, *args, **kwargs):
       serializer =
           self.serializer_class(data=request.data)
       try:
           serializer.is_valid(raise_exception=True)
       except TokenError as e:
           raise InvalidToken(e.args[0])
       return Response(serializer.validated_data,
           status=status.HTTP_200_OK)

将视图集添加到viewsets目录的__init__.py文件中:

from .register import RegisterViewSet
from .login import LoginViewSet

我们现在可以导入它并在routers.py文件中注册它:

core/routers.py

...
from core.auth.viewsets import RegisterViewSet,
    LoginViewSet
router = routers.SimpleRouter()
# ##################################################################### #
# ################### AUTH                       ###################### #
# ##################################################################### #
router.register(r'auth/register', RegisterViewSet,
    basename='auth-register')
router.register(r'auth/login', LoginViewSet,
    basename='auth-login')
...

登录端点将在/auth/login/处可用。让我们用 Insomnia 尝试一个请求。

这是我将使用的请求体的主体:

{
    "password": "12345678",
    "email": "mouse@yopmail.com"
}

图 2.9 – 使用用户凭据登录

图 2.9 – 使用用户凭据登录

登录功能已经准备好并且运行得很好——但我们遇到了一点小问题。访问令牌在 5 分钟后过期。基本上,为了获取新的访问令牌,用户将不得不再次登录。让我们看看我们如何使用刷新令牌来请求新的访问令牌而无需再次登录。

刷新逻辑

djangorestframework-simplejwt提供了刷新逻辑功能。正如你所注意到的,我们每次完成注册或登录时都会生成刷新令牌并将其作为响应返回。我们将从TokenRefreshView类继承并转换成视图集。

auth/viewsets中,添加一个名为refresh.py的新文件:

core/auth/viewsets/refresh.py

from rest_framework.response import Response
from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework import viewsets
from rest_framework_simplejwt.exceptions import TokenError,
    InvalidToken
class RefreshViewSet(viewsets.ViewSet, TokenRefreshView):
   permission_classes = (AllowAny,)
   http_method_names = ['post']
   def create(self, request, *args, **kwargs):
       serializer = self.get_serializer(data=request.data)
       try:
           serializer.is_valid(raise_exception=True)
       except TokenError as e:
           raise InvalidToken(e.args[0])
       return Response(serializer.validated_data,
           status=status.HTTP_200_OK)
Now add the class in the __init__.py file.
from .register import RegisterViewSet
from .login import LoginViewSet
from .refresh import RefreshViewSet

现在将类添加到__init__.py文件中。

core/auth/viewsets/init.py

from .register import RegisterViewSet
from .login import LoginViewSet
from .refresh import RefreshViewSet

现在将其注册到routers.py文件中:

core/routers.py

from core.auth.viewsets import RegisterViewSet,
    LoginViewSet, RefreshViewSet
...
router.register(r'auth/refresh', RefreshViewSet,
    basename='auth-refresh')
...

太好了——让我们测试新的端点/auth/refresh/以获取新的令牌。这将是一个带有请求体中刷新令牌的POST请求,你将在响应中收到新的访问令牌:

图 2.10 – 请求新的访问令牌

图 2.10 – 请求新的访问令牌

太好了——我们刚刚学习了如何在应用程序中实现刷新令牌逻辑。

摘要

在本章中,我们学习了如何使用 DRF 和djangorestframework-simplejwt为 Django 应用程序编写基于 JWT 的认证系统。我们还学习了如何扩展类和重写函数。

在下一章中,我们将添加posts功能。我们的用户将能够创建一个可以被其他用户查看和点赞的帖子。

问题

  1. JWT 是什么?

  2. Django Rest Framework 是什么?

  3. 模型是什么?

  4. 序列化器是什么?

  5. 视图集是什么?

  6. 路由器是什么?

  7. 刷新令牌的用途是什么?

第三章:社交媒体帖子管理

在上一章中,我们介绍了模型、序列化器、视图集和路由来创建我们的第一个端点。在本章中,我们将使用相同的概念为我们的社交媒体项目创建帖子。这将通过将项目划分为数据库关系、过滤和权限等概念来完成。在本章结束时,你将能够使用 Django 模型处理数据库关系,编写自定义过滤和权限,以及删除和更新对象。

在本章中,我们将涵盖以下主题:

  • 创建帖子模型

  • 编写帖子模型

  • 编写帖子序列化器

  • 编写帖子视图集

  • 添加权限

  • 删除和更新帖子

  • 添加点赞功能

技术要求

对于本章,你需要在你的机器上安装 Insomnia 来发送 HTTP 请求。

你可以在这里找到本章的代码:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap3

创建帖子模型

在这个项目中,帖子是一篇长或短的文本,任何人都可以查看,无论用户是否与该帖子相关联。以下是帖子功能的要求数据:

  • 认证用户应该能够创建帖子

  • 认证用户应该能够点赞帖子

  • 所有用户都应该能够阅读帖子,即使他们未认证

  • 帖子的作者应该能够修改帖子

  • 帖子的作者应该能够删除帖子

从后端的角度来看这些需求,我们可以理解我们将要处理数据库、模型和权限。首先,让我们从编写数据库中帖子模型的结构开始。

设计帖子模型

帖子由作者(在这里,是用户)撰写的字符组成的内容。它是如何在我们数据库中结构化的?

在创建帖子模型之前,让我们快速绘制一下数据库中模型结构的图示:

图 3.1 – 帖子表

图 3.1 – 帖子表

图 3.1所示,有一个作者字段,它是一个用户表。每次创建帖子时,都需要传递一个外键。

外键是一对一(或多对一)关系的一个特征。在这种关系中,表 A 中的一行可以在表 B 中有多个匹配行(一对多),但表 B 中的一行只能有一个匹配表 A 的行。

在我们的案例中,一个用户(来自用户表)可以有多个帖子(在帖子表中),但一个帖子只能有一个用户(图 3.2):

图 3.2 – 用户和帖子关系

图 3.2 – 用户和帖子关系

还有两种其他类型的数据库关系:

  • 一对一:在这种关系类型中,表 A 中的一行只能对应表 B 中的一行,反之亦然。例如,工人 C 只有一个且仅有一个桌子 D。而这个桌子 D 只能由这个工人 C 使用(图 3.3.3):

图 3.3 – 工人与桌子之间的一对一关系

图 3.3 – 工人与桌子之间的一对一关系

  • 多对多:在这种数据库关系类型中,表 A 中的一行可以对应表 B 中的多行,反之亦然。例如,在一个电子商务应用中,一个订单可以有多个商品,一个商品也可以出现在多个不同的订单中(图 3.4.4):

图 3.4 – 订单与商品之间的多对多关系

图 3.4 – 订单与商品之间的多对多关系

在编写帖子的“点赞”功能时,将使用多对多关系。

很好,现在我们更好地了解了数据库关系,我们可以开始编写帖子功能,从Post模型开始。但在那之前,让我们快速重构代码以使开发更容易。

抽象

我们接下来要创建的下一个模型也将包含public_idcreatedupdated字段。为了遵循不要重复自己DRY)原则,我们将使用抽象模型类。

一个抽象类可以被视为其他类的蓝图。它通常包含一组必须在从抽象类构建的任何子类中创建的方法或属性。

core目录内,创建一个新的 Python 包,命名为abstract。完成后,创建一个models.py文件。在这个文件中,我们将编写两个类:AbstractModelAbstractManager

AbstractModel类将包含如public_idcreatedupdated等字段。另一方面,AbstractManager类将包含用于通过public_id字段检索对象的函数:

core/abstract/models.py

from django.db import models
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404
class AbstractManager(models.Manager):
   def get_object_by_public_id(self, public_id):
       try:
           instance = self.get(public_id=public_id)
           return instance
       except (ObjectDoesNotExist, ValueError, TypeError):
           return Http404
class AbstractModel(models.Model):
   public_id = models.UUIDField(db_index=True, unique=True,
     default=uuid.uuid4, editable=False)
   created = models.DateTimeField(auto_now_add=True)
   updated = models.DateTimeField(auto_now=True)
   objects = AbstractManager()
   class Meta:
       abstract = True

正如你在AbstractModelMeta类中看到的,abstract属性被设置为True。Django 将忽略这个类模型,并且不会为这个模型生成迁移。

现在我们有了这个类,让我们对User模型进行快速重构:

首先,让我们移除用于通过public_id检索对象的get_object_by_public_id方法,并让UserManager成为子类:

core/user/models.py

…
from core.abstract.models import AbstractModel, AbstractManager
class UserManager(BaseUserManager, AbstractManager):
…
class User(AbstractModel, AbstractBaseUser, PermissionsMixin):
…

User模型上,移除public_idupdatedcreated字段,并且,使用AbstractModel类子类化User模型。这通常不会对数据库造成任何变化,因此,除非你已更改字段的属性,否则无需再次运行makemigrations

让我们再添加一个AbstractSerializer,它将被我们在本项目中创建的所有序列化器使用。

编写抽象序列化器

我们 API 中返回的所有对象都将包含idcreatedupdated字段。在每一个ModelSerializer上再次编写这些字段将是重复的,所以让我们只创建一个AbstractSerializer类。在abstract目录中,创建一个名为serializers.py的文件,并添加以下内容:

core/abstract/serializers.py

from rest_framework import serializers
class AbstractSerializer(serializers.ModelSerializer):
   id = serializers.UUIDField(source='public_id',
                              read_only=True, format='hex')
   created = serializers.DateTimeField(read_only=True)
   updated = serializers.DateTimeField(read_only=True)

完成后,你可以去子类化UserSerializer类,使用AbstractSerializer类:

core/user/serializers.py

from core.abstract.serializers import AbstractSerializer
from core.user.models import User
class UserSerializer(AbstractSerializer):
…

完成后,删除idcreatedupdated字段的声明。

让我们对ViewSets进行最后一次抽象。

编写 AbstractViewSet

但为什么要写一个ViewSet的摘要呢?嗯,会有关于排序和过滤的重复声明。让我们创建一个包含默认值的类。

abstract目录中,创建一个名为viewsets.py的文件,并添加以下内容:

core/abstract/viewsets.py

from rest_framework import viewsets
from rest_framework import filters
class AbstractViewSet(viewsets.ModelViewSet):
   filter_backends = [filters.OrderingFilter]
   ordering_fields = ['updated', 'created']
   ordering = ['-updated']

如你所见,我们有以下属性:

  • filter_backends:这设置了默认的过滤器后端。

  • ordering_fields:这个列表包含在请求时可以作为排序参数使用的字段。

  • ordering:这将告诉 Django REST 以何种顺序发送多个对象作为响应。在这种情况下,所有响应都将按最近更新的顺序排序。

下一步是将AbstractViewSet类添加到代码中,实际上是在调用ModelViewSets的地方。转到core/user/viewsets.py,并使用AbstractViewSet类来子类化UserViewSet

core/user/viewsets.py

…
from core.abstract.viewsets import AbstractViewSet
from core.user.serializers import UserSerializer
from core.user.models import User
class UserViewSet(AbstractViewSet):
…

太好了,现在我们有了编写更好、更少代码所需的所有东西;让我们编写Post模型。

编写 Post 模型

我们已经建立了Post模型的结构。让我们编写代码和功能:

  1. 创建一个名为post的新应用:

    django-admin startapp post
    
  2. 重新编写新创建的包的apps.py,以便在项目中轻松调用:

core/post/apps.py

from django.apps import AppConfig
class PostConfig(AppConfig):
   default_auto_field =
     'django.db.models.BigAutoField'
   name = 'core.post'
   label = "core_label"
  1. 完成后,我们现在可以编写Post模型。打开models.py文件,输入以下内容:

core/post/models.py

from django.db import models
from core.abstract.models import AbstractModel, AbstractManager
class PostManager(AbstractManager):
   pass
class Post(AbstractModel):
   author = models.ForeignKey(to="core_user.User",
     on_delete=models.CASCADE)
   body = models.TextField()
   edited = models.BooleanField(default=False)
   objects = PostManager()
   def __str__(self):
       return f"{self.author.name}"
   class Meta:
       db_table = "'core.post'"

你可以在这里看到我们是如何创建ForeignKey关系的。Django 模型实际上提供了处理这种关系的工具,它也是对称的,这意味着我们不仅可以使用Post.author语法来访问用户对象,还可以使用User.post_set语法来访问用户创建的帖子。后者语法将返回一个包含用户创建的帖子的queryset对象,因为我们处于ForeignKey关系,这同样是一个一对多关系。你也会注意到on_delete属性具有models.CASCADE值。使用CASCADE,如果从数据库中删除用户,Django 也会删除与此用户相关的所有帖子记录。

除了CASCADE作为ForeignKey关系上on_delete属性值的选项之外,你还可以有如下选项:

  • SET_NULL:这将在删除时将子对象的外键设置为 null。例如,如果从数据库中删除用户,则与该用户相关的帖子的author字段值设置为None

  • SET_DEFAULT:这将在写入模型时将子对象设置为给定的默认值。如果你确定默认值不会被删除,则它将工作。

  • RESTRICT:在特定条件下会引发RestrictedError

  • PROTECT:这会阻止外键对象被删除,只要还有对象与外键对象相关联。

让我们通过创建一个对象并将其保存到数据库中来测试新添加的模型:

  1. 将新创建的应用程序添加到INSTALLED_APPS列表中:

CoreRoot/settings.py

…
'core.post'
…
  1. 让我们为新增的应用程序创建迁移:

    python manage makemigrations && python manage.py migrate
    
  2. 然后,让我们使用python manage.py shell命令进行操作:

    (venv) koladev@koladev123xxx:~/PycharmProjects/Full-stack-Django-and-React$ python manage.py shell
    
    Python 3.10.2 (main, Jan 15 2022, 18:02:07) [GCC 9.3.0] on linux
    
    Type "help", "copyright", "credits" or "license" for more information.
    
    (InteractiveConsole)
    
    >>>
    

重要提示

你可以使用django_shell_plus包来加速与 Django shell 的工作。你不需要自己输入所有导入,因为默认情况下所有模型都会被导入。你可以在以下网站上找到有关如何安装它的更多信息:django-extensions.readthedocs.io/en/latest/shell_plus.html

  1. 让我们导入一个用户。这将是我们将要创建的帖子的作者:

    >>> from core.post.models import Post
    
    >>> from core.user.models import User
    
    >>> user = User.objects.first()
    
    >>> user
    
  2. 接下来,让我们创建一个字典,它将包含创建帖子所需的所有字段:

    >>> data = {"author": user, "body":"A simple test"}
    
  3. 现在,让我们创建一个帖子:

    >>> post = Post.objects.create(**data)
    
    >>> post
    
    <Post: John Hey>
    
    >>>
    
    Let's access the author field of this object.
    
    >>> post.author
    
    <User: testuser@yopmail.com>
    

如你所见,作者实际上是我们在数据库中检索到的用户。

让我们也尝试反向关系:

>>> user.post_set.all()
<QuerySet [<Post: John Hey>]>

如你所见,post_set属性包含了与该用户相关联的所有帖子所需的所有交互指令。

现在你已经更好地理解了 Django 中数据库关系的工作方式,我们可以继续编写Post对象的序列化器。

编写 Post 序列化器

Post序列化器将包含在端点请求时创建帖子所需的字段。让我们首先添加帖子创建的功能。

post目录下,创建一个名为serializers.py的文件。在这个文件中,添加以下内容:

core/post/serializers.py

from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from core.abstract.serializers import AbstractSerializer
from core.post.models import Post
from core.user.models import User
class PostSerializer(AbstractSerializer):
   author = serializers.SlugRelatedField(
     queryset=User.objects.all(), slug_field='public_id')
   def validate_author(self, value):
       if self.context["request"].user != value:
           raise ValidationError("You can't create a post
                                  for another user.")
       return value
   class Meta:
       model = Post
       # List of all the fields that can be included in a
       # request or a response
       fields = ['id', 'author', 'body', 'edited',
                 'created', 'updated']
       read_only_fields = ["edited"]

我们添加了一个新的序列化器字段类型,SlugRelatedField。由于我们正在使用ModelSerializer类,Django 会自动为我们处理字段和关系生成。定义我们想要使用的关联字段类型对于告诉 Django 确切要做什么也非常关键。

正是SlugRelatedField在这里发挥作用。它用于使用目标上的字段来表示关系的目标。因此,在创建帖子时,作者的public_id将通过请求体传递,以便用户可以被识别并关联到帖子。

validate_author 方法检查 author 字段的验证。在这里,我们想确保创建帖子的用户与 author 字段中的用户相同。每个序列化器都有一个上下文字典可用。它通常包含我们可以用来进行一些检查的请求对象。

这里没有硬性限制,因此我们可以轻松地进入这个功能的下一部分:编写 Post 视图集。

编写帖子视图集

对于以下端点,我们只允许 POSTGET 方法。这将帮助我们首先实现基本功能。

代码应遵循以下规则:

  • 只有经过身份验证的用户才能创建帖子

  • 只有经过身份验证的用户才能读取帖子

  • 只允许使用 GETPOST 方法

post 目录中,创建一个名为 viewsets.py 的文件。在文件中,添加以下内容:

core/post/viewsets.py

from rest_framework.permissions import IsAuthenticated
from core.abstract.viewsets import AbstractViewSet
from core.post.models import Post
from core.post.serializers import PostSerializer
class PostViewSet(AbstractViewSet):
   http_method_names = ('post', 'get')
   permission_classes = (IsAuthenticated,)
   serializer_class = PostSerializer
   def get_queryset(self):
       return Post.objects.all()
   def get_object(self):
       obj = Post.objects.get_object_by_public_id(
         self.kwargs['pk'])
       self.check_object_permissions(self.request, obj)
       return obj
   def create(self, request, *args, **kwargs):
       serializer = self.get_serializer(data=request.data)
       serializer.is_valid(raise_exception=True)
       self.perform_create(serializer)
       return Response(serializer.data,
                       status=status.HTTP_201_CREATED)

在前面的代码中,我们定义了三个有趣的方法:

  • get_queryset 方法返回所有帖子。我们实际上没有特定的获取帖子要求,因此我们可以返回数据库中的所有帖子。

  • get_object 方法使用 public_id 返回一个 post 对象,该 public_id 将出现在 URL 中。我们从 self.kwargs 目录中检索此参数。

  • create 方法,这是在 ViewSetPOST 请求端点执行的 ViewSet 动作。我们只需将数据传递给在 ViewSet 上声明的序列化器,验证数据,然后调用 perform_create 方法来创建 post 对象。此方法将自动通过调用 Serializer.create 方法来处理 post 对象的创建,这将触发在数据库中创建 post 对象。最后,我们返回一个包含新创建帖子的响应。

正在这里,你有 ViewSet 的代码。下一步是添加端点并开始测试 API。

添加帖子路由

routers.py 文件中,添加以下内容:

core/routers.py

…
from core.post.viewsets import PostViewSet
# ##################################################################### #
# ################### POST                       ###################### #
# ##################################################################### #
router.register(r'post', PostViewSet, basename='post')
…

完成后,你将在 /post/ 路径下获得一个新的端点。让我们用 Insomnia 来测试 API。

首先,尝试直接向 /post/ 端点发送请求。你会收到一个 /auth/login/ 端点,并使用注册用户复制令牌。

在 Insomnia 的 Bearer 选项卡中,选择 Bearer Token

图 3.5 – 将 Bearer Token 添加到 Insomnia 请求中

图 3.5 – 将 Bearer Token 添加到 Insomnia 请求中

现在,再次使用 GET 请求触发端点。你会看到没有结果,太好了!让我们在数据库中创建第一个帖子。

将请求类型更改为 POST 并将以下内容更改为 JSON 主体:

{
    "author": "19a2316e94e64c43850255e9b62f2056",
    "body": "A simple posted"
}

请注意,我们将有一个不同的 public_id,请确保使用你刚刚登录的用户 public_id 并重新发送请求:

图 3.6 – 创建帖子

图 3.6 – 创建帖子

好的,帖子已创建!让我们看看在发送 GET 请求时它是否可用:

图 3.7 – 获取所有帖子

图 3.7 – 获取所有帖子

DRF 提供了一种分页响应的方式,并在 settings.py 文件中设置了一个全局默认分页限制大小。随着时间的推移,将显示很多对象,并且有效负载的大小也会变化。

为了防止这种情况,让我们添加一个默认大小和一个类来分页我们的结果。

在项目的 settings.py 文件中,向 REST_FRAMEWORK 字典添加新的设置:

CoreRoot/settings.py

REST_FRAMEWORK = {
…
   'DEFAULT_PAGINATION_CLASS':
     'rest_framework.pagination.LimitOffsetPagination',
   'PAGE_SIZE': 15,
}
…

基本上在这里,所有结果每页限制为 15 个,但我们在请求时也可以通过 limit 参数增加这个大小,并使用 offset 参数精确到我们想要结果开始的精确位置:

GET https://api.example.org/accounts/?limit=100&offset=400

太好了,现在再次进行 GET 请求,你会看到结果结构更清晰。

此外,在响应中包含作者的名字也会更实用。让我们重写一个序列化方法,以帮助修改响应对象。

重写帖子序列化对象

实际上,author 字段接受 public_id 并返回 public_id。虽然它完成了工作,但可能有点难以识别用户。这会导致它再次使用用户的 public_id 发起请求,以获取关于用户的信息片段。

to_representation() 方法接受需要序列化的对象实例,并返回一个原始表示。这通常意味着返回一个内置 Python 数据类型的结构。可以处理的确切类型取决于你为 API 配置的渲染类。

post/serializers.py 内,添加一个名为 to_represenation() 的新方法:

core/post/serializers.py

class PostSerializer(AbstractSerializer):
   …
   def to_representation(self, instance):
       rep = super().to_representation(instance)
       author = User.objects.get_object_by_public_id(
         rep["author"])
       rep["author"] = UserSerializer(author).data
       return rep
…

如你所见,我们正在使用 public_id 字段来检索用户,然后使用 UserSerializer 序列化 User 对象。

让我们再次获取所有帖子,你会看到所有用户:

图 3.8 – 获取所有帖子

图 3.8 – 获取所有帖子

我们有一个工作的 Post 功能,但它也有一些问题。当为我们的功能编写写入权限时,让我们进一步探讨这个问题。

添加权限

如果认证是验证用户身份的行为,那么授权就是简单地检查用户是否有执行该行为的权利或特权。

在我们的项目中,我们有三种类型的用户:

  • 匿名用户:此用户在 API 上没有账户,实际上无法被识别

  • 注册和活跃用户:此用户在 API 上有账户,可以轻松执行一些操作

  • 管理员用户:此用户拥有所有权利和特权

我们希望匿名用户能够在不必要认证的情况下读取 API 上的帖子。虽然确实存在 AllowAny 权限,但它肯定会与 IsAuthenticated 权限冲突。

因此,我们需要编写一个自定义权限。

authentication 目录内,创建一个名为 permissions 的文件,并添加以下内容:

core/post/viewsets.py

from rest_framework.permissions import BasePermission, SAFE_METHODS
class UserPermission(BasePermission):
   def has_object_permission(self, request, view, obj):
       if request.user.is_anonymous:
           return request.method in SAFE_METHODS
       if view.basename in ["post"]:
           return bool(request.user and
                       request.user.is_authenticated)
    return False
   def has_permission(self, request, view):
       if view.basename in ["post"]:
           if request.user.is_anonymous:
               return request.method in SAFE_METHODS
           return bool(request.user and
                       request.user.is_authenticated)
       return False

Django 权限通常在两个级别上工作:在整体端点(has_permission)和对象级别(has_object_permission)。

编写权限的一个好方法是一直默认拒绝;这就是为什么我们总是在每个权限方法结束时返回False。然后你可以开始添加条件。在这里,在所有方法中,我们都在检查匿名用户只能进行SAFE_METHODS请求——GETOPTIONSHEAD

对于其他用户,我们确保他们在继续之前总是经过认证。另一个重要功能是允许用户删除或更新帖子。让我们看看我们如何使用 Django 来实现这一点。

删除和更新帖子

删除和更新文章也是帖子功能的一部分。为了添加这些功能,我们不需要编写序列化器或视图集,因为删除(destroy())和更新(update())的方法默认已经在ViewSet类中可用。我们只需重写PostSerializer上的update方法,以确保在修改帖子时将edited字段设置为True

让我们在PostViewSethttp_methods中添加PUTDELETE方法:

core/post/viewsets.py

…
class PostViewSet(AbstractViewSet):
   http_method_names = ('post', 'get', 'put', 'delete')
…

在进入之前,让我们重写PostSerializer中的update方法。实际上,我们在Post模型中有一个名为edited的字段。这个字段将告诉我们帖子是否被编辑过:

core/post/serializers.py

…
class PostSerializer(AbstractSerializer):
…
   def update(self, instance, validated_data):
       if not instance.edited:
           validated_data['edited'] = True
       instance = super().update(instance, validated_data)
       return instance
…

让我们在 Insomnia 中尝试PUTDELETE请求。以下是PUT请求的示例正文:

{
    "author": "61c5a1ecb9f5439b810224d2af148a23",
    "body": "A simple post edited"
}

图 3.9 – 修改帖子

图 3.9 – 修改帖子

如您所见,响应中的edited字段被设置为true

让我们尝试删除帖子,看看它是否工作:

图 3.10 – 删除帖子

图 3.10 – 删除帖子

重要提示

有一种方法可以在不必要从数据库中删除记录的情况下删除记录。这通常被称为软删除。记录将无法被用户访问,但它将始终存在于数据库中。您可以在dev.to/bikramjeetsingh/soft-deletes-in-django-a9j了解更多相关信息。

添加点赞功能

在社交媒体应用中拥有一个不错的功能就是点赞。就像 Facebook、Instagram 或 Twitter 一样,我们在这里将允许用户点赞帖子。

此外,我们还将添加数据来统计帖子的点赞数,并检查当前发起请求的用户是否点赞了帖子。

我们将分四个步骤来完成这项工作:

  1. User模型添加新的posts_liked字段。

  2. User模型上编写点赞和取消点赞帖子的方法。我们还将添加一个方法来检查用户是否点赞了帖子。

  3. likes_counthas_liked添加到PostSerializer

  4. 添加点赞和踩帖子的端点。

太好了!让我们先向User模型添加新的字段。

向 User 模型添加 posts_liked 字段

posts_liked 字段将包含用户喜欢的所有帖子。关于点赞功能的 User 模型和 Post 模型之间的关系可以描述如下:

  • 一个用户可以点赞多个帖子

  • 一个帖子可以被多个用户点赞

这种关系听起来熟悉吗?这是一个 多对多 关系。

随着这个更改,以下是表的更新结构 – 我们也在预测我们将添加到模型中的方法:

图 3.11 – 新用户表结构

图 3.11 – 新用户表结构

太好了!让我们将 posts_liked 字段添加到 User 模型中。打开 /core/user/models.py 文件,并在 User 模型中添加一个新字段:

class User(AbstractModel, AbstractBaseUser, PermissionsMixin):
...
   posts_liked = models.ManyToManyField(
       "core_post.Post",
       related_name="liked_by"
   )
...

然后,运行以下命令以创建一个新的迁移文件并将此迁移应用到数据库中:

python manage.py makemigrations
python manage.py migrate

下一步是将图 3.11 中显示的新方法添加到 User 模型中。

添加点赞、取消点赞和 has_liked 方法

在编写这些方法之前,让我们描述一下每个新方法的目的:

  • like() 方法:这个方法用于在尚未点赞的情况下点赞帖子。为此,我们将使用模型中的 add() 方法。我们将使用 ManyToManyField 来将帖子链接到用户。

  • remove_like() 方法:这个方法用于从帖子中移除点赞。为此,我们将使用模型中的 remove 方法。我们将使用 ManyToManyField 来解除帖子与用户的链接。

  • has_liked() 方法:这个方法用于返回用户是否喜欢了一个帖子,如果是则返回 True,否则返回 False

让我们继续编写代码:

class User(AbstractModel, AbstractBaseUser, PermissionsMixin):
   ...
   def like(self, post):
       """Like `post` if it hasn't been done yet"""
       return self.posts_liked.add(post)
   def remove_like(self, post):
       """Remove a like from a `post`"""
       return self.posts_liked.remove(post)
   def has_liked(self, post):
       """Return True if the user has liked a `post`; else
          False"""
       return self.posts_liked.filter(pk=post.pk).exists()

太好了!接下来,让我们将 likes_counthas_liked 字段添加到 PostSerializer

将 likes_count 和 has_liked 字段添加到 PostSerializer

而不是在 Post 模型中添加如 likes_count 这样的字段并在数据库中生成更多字段,我们可以在 PostSerializer 上直接管理它。Django 中的 Serializer 类提供了创建将在响应中发送的 write_only 值的方法。

core/post/serializers.py 文件中,向 PostSerializer 添加新字段:

Core/post/serializers.py

...
class PostSerializer(AbstractSerializer):
   ...
   liked = serializers.SerializerMethodField()
   likes_count = serializers.SerializerMethodField()
   def get_liked(self, instance):
       request = self.context.get('request', None)
       if request is None or request.user.is_anonymous:
           return False
       return request.user.has_liked(instance)
   def get_likes_count(self, instance):
       return instance.liked_by.count()
   class Meta:
       model = Post
       # List of all the fields that can be included in a
       # request or a response
       fields = ['id', 'author', 'body', 'edited', 'liked',
                 'likes_count', 'created', 'updated']
       read_only_fields = ["edited"]

在前面的代码中,我们使用了 serializers.SerializerMethodField() 字段,它允许我们编写一个自定义函数,该函数将返回我们想要分配给此字段的价值。方法的语法将是 get_field,其中 field 是在序列化器上声明的字段的名称。

因此,对于 liked,我们有 get_liked 方法,对于 likes_count,我们有 get_likes_count 方法。

PostSerializer 上的新字段允许我们添加到 PostViewSet 的端点,以点赞或踩踏文章。

将点赞和踩踏操作添加到 PostViewSet

DRF 提供了一个名为 action 的装饰器。这个装饰器有助于使 ViewSet 类上的方法可路由。action 装饰器接受两个参数:

  • detail:如果此参数设置为 True,则此操作的路径将需要一个资源查找字段;在大多数情况下,这将是指资源的 ID

  • methods:这是动作接受的方方法的列表

让我们在PostViewSets上编写动作:

core/post/viewsets.py

 ...
class PostViewSet(AbstractViewSet):
   ...
   @action(methods=['post'], detail=True)
   def like(self, request, *args, **kwargs):
       post = self.get_object()
       user = self.request.user
       user.like(post)
       serializer = self.serializer_class(post)
       return Response(serializer.data,
                       status=status.HTTP_200_OK)
   @action(methods=['post'], detail=True)
   def remove_like(self, request, *args, **kwargs):
       post = self.get_object()
       user = self.request.user
       user.remove_like(post)
       serializer = self.serializer_class(post)
       return Response(serializer.data,
                       status=status.HTTP_200_OK)

对于每个添加的动作,我们按照以下步骤编写逻辑:

  1. 首先,我们检索我们想要调用点赞或取消点赞动作的相关帖子。self.get_object()方法将自动返回相关的帖子,这是通过将detail属性设置为True,并使用传递给 URL 请求的 ID 来实现的。

  2. 其次,我们还从self.request对象中检索发起请求的用户。这样做是为了我们可以调用添加到User模型中的remove_likelike方法。

  3. 最后,我们使用在self.serializer_class上定义的Serializer类序列化帖子,并返回一个响应。

将此添加到PostViewSets后,Django Rest Framework 路由器将自动为该资源创建新的路由,然后,你可以做以下操作:

  1. 使用以下端点点赞帖子:api/post/post_pk/like/

  2. 使用以下端点移除帖子的点赞:api/post/post_pk/remove_like/

太棒了,这个功能运行得像魔法一样。在下一章,我们将向项目中添加注释功能。

摘要

在本章中,我们学习了如何使用数据库关系和编写权限。我们还学习了如何在视集合和序列化器上覆盖更新和创建方法。

我们通过创建一个Abstract类来遵循DRY规则,对代码进行了快速重构。在下一章,我们将添加注释功能到帖子中。用户将能够在帖子下创建评论,以及删除和更新它们。

问题

  1. 一些数据库关系有哪些?

  2. Django 权限有哪些?

  3. 你如何分页 API 响应的结果?

  4. 你如何使用 Django shell?

第四章:向社交媒体帖子添加评论

如果你的用户可以在其他帖子下评论甚至点赞,那么社交媒体应用会更有趣。在本章中,我们将首先学习如何向帖子添加评论。我们将看到如何再次使用数据库关系为每个帖子创建一个评论部分,并确保代码质量得到保持。

在本章中,我们将涵盖以下主题:

  • 编写评论模型

  • 编写评论序列化器

  • 为评论资源嵌套路由

  • 编写 CommentViewSet 类

  • 更新评论

  • 删除评论

到本章结束时,你将能够创建 Django 模型,编写 Django 序列化器和验证,编写嵌套视图集和路由,并对授权权限有更好的理解。

技术要求

对于本章,你需要安装 Insomnia 并了解一些关于模型、数据库关系和权限的知识。你还需要在你的机器上安装 Insomnia API 客户端。本章的代码可以在以下位置找到:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap4

编写评论模型

在本项目的上下文中,一个评论将代表一个任何人都可以查看但只有认证用户可以创建或更新的简短文本。以下是该功能的需求:

  • 任何用户都可以阅读评论

  • 认证用户可以在帖子下创建评论

  • 评论作者和帖子作者可以删除评论

  • 评论作者可以更新帖子

观察这些需求,我们肯定可以从编写模型开始。但首先,让我们快速谈谈数据库中评论表的结构:

图 4.1 – 评论表结构

图 4.1 – 评论表结构

一个评论通常有四个重要的字段:评论的作者、评论所在的帖子、评论正文以及编辑字段,用于跟踪评论是否被编辑。

图 4.1 所示,我们在表中有两个数据库关系:作者和帖子。那么,这个结构在数据库中是如何体现的呢?

图 4.2 – 评论、帖子与用户关系

图 4.2 – 评论、帖子与用户关系

图 4.2 所示,作者(User)和帖子(Post)字段是外键类型。这关系到评论功能的某些规则:

  • 一个用户可以有多个评论,但一个评论是由一个用户创建的

  • 一个帖子可以有多个评论,但一个评论只与一个帖子相关联

现在我们已经有了表的结构和对需求的更好理解,让我们编写模型并对其进行测试。

添加评论模型

core/comment/models.py中添加以下内容:

core/comment/models.py

from django.db import models
from core.abstract.models import AbstractModel, AbstractManager
class CommentManager(AbstractManager):
    pass
class Comment(AbstractModel):
    post = models.ForeignKey("core_post.Post",
                              on_delete=models.PROTECT)
    author = models.ForeignKey("core_user.User",
                                on_delete=models.PROTECT)
    body = models.TextField()
    edited = models.BooleanField(default=False)
    objects = CommentManager()
    def __str__(self):
        return self.author.name

在前面的代码片段中,我们声明了一个名为CommentManager的类,它是AbstractManager类的子类。然后,我们声明了Comment模型类,其中包含postauthor字段,这些字段分别与Post模型和User模型相关联,是ForeignKey字段。最后,我们声明了正文和编辑字段。其余的代码是基本的礼节,比如告诉 Django 使用 Manager 类来管理Comment模型,最后在 Django shell 中检查评论对象时返回作者的默认__str__方法。

现在已经编写了Comment模型,让我们在 Django shell 中玩一下这个模型。

在 Django shell 中创建评论

使用以下评论启动 Django shell:

python manage.py shell

Python 3.10.2 (main, Jan 15 2022, 18:02:07) [GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from core.comment.models import Comment
>>> from core.post.models import Post
>>> from core.user.models import User

首先,我们正在导入所需的模型以检索和创建一个评论。接下来,我们将检索一个用户和一个帖子,然后将创建评论所需的数据写入一个 Python 字典,如下所示:

>>> user = User.objects.first()
>>> post = Post.objects.first()
>>> comment_data = {"post": post, "author": user, "body": "A comment."}

现在我们可以按照以下方式创建评论:

>>> comment = Comment.objects.create(**comment_data)
>>> comment
<Comment: Dingo Dog>
>>> comment.body
'A comment.'

太好了,现在我们确信评论功能正常,我们可以编写评论功能的序列化器。

编写评论序列化器

评论序列化器将帮助进行验证和内容创建。在评论应用程序中,创建一个名为serializers.py的文件。我们将在这个文件中编写CommentSerializer

首先,让我们导入创建序列化器所需的类和工具:

/core/comment/serializers.py

from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from core.abstract.serializers import AbstractSerializer
from core.user.models import User
from core.user.serializers import UserSerializer
from core.comment.models import Comment
from core.post.models import Post

一旦完成,我们现在可以编写CommentSerializer

/core/comment/serializers.py

...
class CommentSerializer(AbstractSerializer):
   author = serializers.SlugRelatedField(
     queryset=User.objects.all(), slug_field='public_id')
   post = serializers.SlugRelatedField(
     queryset=Post.objects.all(), slug_field='public_id')
   def to_representation(self, instance):
       rep = super().to_representation(instance)
       author =
        User.objects.get_object_by_public_id(rep["author"])
       rep["author"] = UserSerializer(author).data
       return rep
   class Meta:
       model = Comment
       # List of all the fields that can be included in a
       # request or a response
       fields = ['id', 'post', 'author', 'body', 'edited',
                 'created', 'updated']
       read_only_fields = ["edited"]

让我们解释与CommentSerializer类相关的代码。要创建一个评论,我们需要三个字段:作者的public_id、帖子的public_id以及最后的正文。我们还为author字段添加了验证方法。

validate_author中,我们阻止用户为其他用户创建评论。

最后,to_representation方法通过添加有关作者的信息来修改最终对象。

评论序列化器现在已准备就绪。我们现在可以继续编写与评论功能相关的视图集。但在那之前,让我们谈谈资源的端点。

为评论资源嵌套路由

要创建、更新或删除评论,我们需要添加ViewSet。在comment目录中创建一个名为viewsets.py的文件。这个文件将包含CommentViewSet类的代码。我们不会为这个视图集编写完整的代码,因为我们需要清楚地了解端点的结构。

因此,暂时添加以下内容:

core/comment/viewsets.py

from django.http.response import Http404
from rest_framework.response import Response
from rest_framework import status
from core.abstract.viewsets import AbstractViewSet
from core.comment.models import Comment
from core.comment.serializers import CommentSerializer
from core.auth.permissions import UserPermission
class CommentViewSet(AbstractViewSet):
   http_method_names = ('post', 'get', 'put', 'delete')
   permission_classes = (UserPermission,)
   serializer_class = CommentSerializer
...

太好了,现在让我们谈谈端点架构。以下表格显示了与评论相关的端点结构。您有方法、端点的 URL,以及最终在端点上调用时的结果:

方法 URL 结果
GET /api/comment/ 列出与帖子相关的所有评论
GET /api/comment/comment_pk/ 获取特定的评论
POST /api/comment/ 创建一条评论
PUT /api/comment/comment_pk/ 修改一条评论
DELETE /api/comment/comment_pk/ 删除一条评论

然而,对于评论功能,我们正在处理帖子。如果评论直接与帖子相关,那绝对是一个好主意。因此,我们端点的一个很好的结构将如下所示:

方法 URL 操作
GET /api/post/post_pk/comment/ 列出与帖子相关的所有评论
GET /api/post/post_pk/comment/comment_pk/ 获取特定的评论
POST /api/post/post_pk/comment/ 创建一条评论
PUT /api/post/post_pk/comment/comment_pk/ 修改一条评论
DELETE /api/post/post_pk/comment/comment_pk/ 删除一条评论

在这个结构中,端点是嵌套的,这意味着评论资源位于帖子资源之下。

但我们如何简单地实现这一点呢?

Django 生态系统有一个名为drf-nested-routers的库,它有助于在 Django 项目中编写路由器以创建嵌套资源。

您可以使用以下命令安装此软件包:

pip install drf-nested-routers

不要忘记在requirements.txt文件中添加依赖项。

太好了!不需要在settings.py文件中注册它,因为它不包含信号、模型或应用程序。

在下一节中,让我们配置这个库以满足本项目的需求。

创建嵌套路由

按照以下步骤配置drf-nested-routers库:

  1. 首件事是重写routers.py文件:

core/routers.py

from rest_framework_nested import routers
...
router = routers.SimpleRouter()
…

drf-nested-routers附带一个扩展的SimpleRouter,这对于创建嵌套路由非常有用。

  1. 然后,创建一个新的嵌套路由POST

    ...
    
    # ##################################################################### #
    
    # ################### POST                       ###################### #
    
    # ##################################################################### #
    
    router.register(r'post', PostViewSet, basename='post')
    
    posts_router = routers.NestedSimpleRouter(router, r'post', lookup='post')
    

NestedSimpleRouterSimpleRouter类的子类,它接受初始化参数,例如parent_routerrouterparent_prefixr'post' – 以及查找 – post。查找是匹配父资源实例(PostViewSet)的正则表达式变量。

在我们的案例中,查找正则表达式将是post_pk

  1. 下一步是在post_router上注册comment路由:

core/routers.py

...
# ##################################################################### #
# ################### POST                       ###################### #
# ##################################################################### #
router.register(r'post', PostViewSet, basename='post')
posts_router = routers.NestedSimpleRouter(router, r'post', lookup='post')
posts_router.register(r'comment', CommentViewSet, basename='post-comment')
urlpatterns = [
   *router.urls,
   *posts_router.urls
]
...

太好了!评论资源现在可用,但我们必须在CommentViewSet类上重新编写createget_objectget_queryset方法。让我们看看在下一节中如何使用嵌套路由来修改获取对象的逻辑。

编写 CommentViewSet 类

我们现在对端点的工作方式有了清晰的认识。

按照以下步骤在core/comment/viewsets.py文件中完成CommentViewSet类的编写:

  1. 重新编写CommentViewSet类的get_queryset方法以适应端点的新架构:

core/comment/viewsets.py

...
class CommentViewSet(AbstractViewSet):
...
   def get_queryset(self):
       if self.request.user.is_superuser:
           return Comment.objects.all()
       post_pk = self.kwargs['post_pk']
       if post_pk is None:
           return Http404
       queryset = Comment.objects.filter(
         post__public_id=post_pk)
       return queryset

在前面的代码中,get_queryset 是当用户点击 /api/post/post_pk/comment/ 端点时调用的方法。这里的第一个验证是检查用户是否是超级用户。如果是这样,我们将返回数据库中所有的评论对象。

如果用户不是超级用户,我们将返回与帖子相关的评论。通过嵌套路由的帖子,我们将 lookup 属性设置为 post。这意味着在每次请求的 kwargs(包含额外数据的字典)中,将传递一个具有字典键 post_pkpost 的公共 ID 值到端点的 URL 中。

如果不是这样,我们只返回一个 404 未找到响应。

我们通过过滤和检索只有 post.public_id 字段等于 post_pk 的评论来对数据库进行查询。这是通过 Django ORM 提供的过滤方法完成的。编写用于从数据库检索对象的条件的代码是有用的。

  1. 接下来,让我们向同一个 CommentViewSet 添加 get_object 方法,这样我们就可以使用 public_id 来检索特定的评论:

core/comment/viewsets.py

...
class CommentViewSet(AbstractViewSet):
...
   def get_object(self):
       obj = Comment.objects.get_object_by_public_id(
         self.kwargs['pk'])
       self.check_object_permissions(self.request,
                                     obj)
       return obj
...

UserViewSet 上的 get_object 方法类似,这个方法在每次向 /api/post/post_pk/comment/comment_pk/ 端点发出的请求上被调用。在这里,pkcomment_pk 表示。

然后,我们检索对象并检查权限。如果一切正常,我们返回该对象。

  1. 作为最后一步,让我们编写 create 方法:

core/comment/viewsets.py

...
class CommentViewSet(AbstractViewSet):
...
   def create(self, request, *args, **kwargs):
       serializer =
         self.get_serializer(data=request.data)
       serializer.is_valid(raise_exception=True)
       self.perform_create(serializer)
       return Response(serializer.data,
                       status=status.HTTP_201_CREATED)

PostViewSet 上的 create 方法类似,我们将 request.data 传递给 ViewSet 序列化器 – CommentSerialier – 并尝试验证序列化器。

如果一切正常,我们将根据 CommentSerializer 创建一个新对象 – 一个新的评论。

太好了!我们现在有一个完全功能的 ViewSet。接下来,让我们使用 Insomnia 测试这些功能。

使用 Insomnia 测试评论功能

在尝试检索评论之前,让我们按照以下步骤在 /api/post/post_id/comment/ URL 上使用 POST 创建一些评论:

  1. post_id 替换为你已经创建的帖子的 public_id

这里是这个请求的负载示例:

{
    "author": "61c5a1ecb9f5439b810224d2af148a23",
    "body": "Hey! I like your post.",
    "post": "e2401ac4b29243e6913bd2d4e0944862"
}

这里是向 Insomnia 发起创建评论请求的截图:

图 4.3 – 创建帖子

图 4.3 – 创建帖子

  1. 太好了!现在,将请求类型从 POST 更改为 GET。你将获得所有关于帖子的评论:

图 4.4 – 列出所有评论

图 4.4 – 列出所有评论

现在可以无问题地创建评论了,让我们添加更新评论和删除评论的功能。

更新评论

更新评论是一个只能由评论作者执行的操作。用户只能更新评论的正文字段,不能修改作者值。按照以下步骤添加更新功能:

  1. core/comment/viewsets 中,确保 put 包含在 CommentViewSet 类的 http_method_names 列表中:

core/comment/viewsets

...
class CommentViewSet(AbstractViewSet):
   http_method_names = ('post', 'get', 'put',
                        'delete')
...

之后,让我们为 post 字段编写一个 validate 方法。我们想确保在 PUT 请求中此值不可编辑。

  1. core/comment/serializers.py 文件中,为 CommentSerializer 添加一个名为 validate_post 的新方法:

core/comment/serializers.py

...
def validate_post(self, value):
   if self.instance:
       return self.instance.post
   return value
...

每个模型序列化器都提供了一个 instance 属性,该属性包含在 deleteputpatch 请求中将要修改的对象。如果是 GETPOST 请求,则此属性设置为 None

  1. 接下来,让我们重新编写 CommentSerializer 类上的 update 方法。我们将重写这个类,将编辑后的值传递给 True

core/comment/serializers.py

...
class CommentSerializer(AbstractSerializer):
   ...
   def update(self, instance, validated_data):
       if not instance.edited:
           validated_data['edited'] = True
       instance = super().update(instance,
                                 validated_data)
       return instance
…
  1. 太好了!现在,让我们在 Insomnia 的 /api/post/post_pk/comment/comment_pk/ 端点上尝试一个 PUT 请求。以下是请求的 JSON 体的一个示例:

    {
    
        "author": "61c5a1ecb9f5439b810224d2af148a23",
    
        "body": "A simple comment edited",
    
        "post": "e2401ac4b29243e6913bd2d4e0944862"
    
    }
    

这里是 Insomnia 中 PUT 请求的截图:

图 4.5 – 修改帖子

图 4.5 – 修改帖子

你会在响应体中注意到 edited 字段被设置为 true,评论的内容也发生了变化。

现在可以修改评论了,让我们添加删除评论的功能。

删除评论

删除评论是只有帖子的作者、评论的作者和超级用户才能执行的操作。为了实现这个规则,我们将在 UserPermission 类中添加一些权限,按照以下步骤进行:

  1. 确保在 CommentViewSet 类的 http_method_names 列表中包含 delete

core/comment/viewsets

...
class CommentViewSet(AbstractViewSet):
   http_method_names = ('post', 'get', 'put',
                        'delete')
…
  1. 完成后,让我们在 core/auth/permissions 文件中 UserPermission 类的 has_object_permission 方法中添加更多验证:

core/auth/permissions

...
def has_object_permission(self, request, view, obj):
...
   if view.basename in ["post-comment"]:
       if request.method in ['DELETE']:
           return bool(request.user.is_superuser or
                       request.user in [obj.author,
                       obj.post.author])
       return bool(request.user and
                   request.user.is_authenticated)
…

所有请求都可以在 post-comment 端点进行。然而,如果请求的方法是 DELETE,我们将检查用户是否是超级用户、评论的作者或帖子的作者。

  1. 让我们在 Insomnia 的此端点 /api/post/post_pk/comment/comment_pk/ 上尝试删除评论。确保你有帖子的作者或评论作者的访问令牌。

这里是一个删除帖子下评论的 DELETE 请求的截图:

图 4.6 – 删除帖子

图 4.6 – 删除帖子

太好了,功能运行得非常顺畅。我们刚刚学习了如何为 DELETE 请求编写权限。

摘要

在本章中,我们学习了如何在社交媒体项目中为帖子创建评论功能。这使我们进一步了解了如何使用嵌套路由更好地构建端点,以及如何编写自定义权限。

我们还深入研究了序列化器验证以及它们在不同 HTTP 请求上的工作方式。

在下一章中,我们将专注于为项目添加的每个功能编写单元和集成测试。

问题

  1. 什么是嵌套路由?

  2. 什么是 drf-nested-routers

  3. 模型序列化器上的哪个属性可以帮助你判断请求是 PUT 还是 DELETE 请求?

第五章:测试 REST API

在软件工程中,测试是一个检查实际软件产品是否按预期运行且无错误的过程。

有很多方法可以通过手动和自动测试来测试软件。但在本项目,我们将更多地关注自动化测试。然而,我们首先会深入了解软件测试的不同方式,包括它们的优缺点,以及讨论测试金字塔的概念。我们还将检查添加测试到 Django 应用程序所需的工具,以及如何为模型和视图集添加测试。本章将帮助您了解开发者的测试以及如何为 Django API 编写测试。

在本章中,我们将涵盖以下主题:

  • 什么是测试?

  • Django 中的测试

  • 配置测试环境

  • 为 Django 模型编写测试

  • 为 Django 视图集编写测试

技术要求

您可以在以下链接找到当前章节的代码:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap5.

什么是测试?

简单来说,测试就是找出某物工作得有多好。

然而,这个过程包括一系列技术,用于确定在脚本或直接在用户界面上的手动测试下应用程序的正确性。目的是检测应用程序中的失败,包括错误和性能问题,以便它们可以被纠正。

大多数时候,测试是通过将软件需求与实际软件产品进行比较来完成的。如果其中一个需求是确保输入只接受数字而不是字符或文件,那么将进行一项测试来检查输入是否有验证系统以拒绝输入中的非数值。

然而,测试还涉及对代码的检查以及在各种环境和条件下执行代码。

什么是软件测试?

软件测试是检查软件在测试下的行为以进行验证或验证的过程。它考虑了可靠性、可伸缩性、可重用性和可用性等属性,以评估软件组件(服务器、数据库、应用程序等)的执行,并找出软件错误、错误或缺陷。

软件测试有很多好处,以下是一些:

  • 成本效益:测试任何软件项目都有助于企业在长期内节省资金。因为这个过程有助于检测错误,并检查新添加的功能是否在系统中运行良好而不会破坏其他东西,所以它是一个很好的技术债务减少者。

  • 安全性:如果测试做得好,它可以是一个快速检测在产品部署到整个世界之前早期阶段的安全风险和问题的方法。

  • 产品质量:测试有助于性能测量,确保需求得到尊重。

为什么软件测试很重要?

测试你的软件很重要,因为它有助于通过漏洞识别和解决来减少错误的影响。一些错误可能非常危险,可能导致经济损失或危及人类生命。以下是一些历史例子:

来源:lexingtontechnologies.ng/software-testing/.

  • 1999 年 4 月,由于一次军事卫星发射失败,损失了 12 亿美元。到目前为止,这是世界上成本最高的事故。

  • 2014 年,由于安全气囊传感器中的软件故障,日产汽车召回了一百多万辆汽车。

  • 2014 年,由于软件故障,亚马逊的一些第三方零售商损失了大量资金。该漏洞影响了产品的价格,将它们降至 1 便士。

  • 2015 年,星巴克商店的销售点(POS)系统出现软件故障,导致其在美加的超过 60%的门店临时关闭。

  • 2015 年,F-35 战斗机因软件漏洞而成为受害者,该漏洞阻止了它正确检测或识别目标。飞机上的传感器甚至无法识别来自它们自己的飞机的威胁。

  • 2016 年,谷歌报告了一个影响 Windows 10 机器的漏洞。该漏洞允许用户通过 win32k 系统中的流程绕过安全沙箱。

有哪些不同的测试类型?

测试通常分为三类:

  • 功能性测试:这种测试包括单元测试、集成测试、用户接受测试、全球化测试、国际化测试等

  • 非功能性测试:这种测试检查性能、容量、可扩展性、可用性和负载等因素

  • 维护测试:这种测试考虑了回归和维护

然而,这些测试也可以分为两种不同的类型:

  • 自动化测试

  • 手动测试

首先,让我们看看什么是手动测试。

理解手动测试

手动测试是指手动测试软件以发现缺陷或错误的过程。这是在没有自动化工具帮助的情况下测试应用程序功能的过程。

手动测试的一个例子是当测试用户被召集来测试一个应用程序或一个特殊功能。他们可能被要求测试一个特定的表单,在性能方面将应用程序推到极限,等等。

手动测试有很多优点:

  • 它非常有助于测试用户界面设计和交互

  • 对于新测试员来说更容易学习

  • 它考虑了用户体验和可用性

  • 它具有成本效益

然而,手动测试也有一些缺点:

  • 这需要人力资源。

  • 这很耗时。

  • 测试员根据他们的技能和经验考虑测试用例。这意味着一个初级测试员可能无法覆盖所有功能。

即使手动测试听起来非常吸引人,它也可能是一项耗时且资源消耗很大的活动,而开发者绝对不是真正优秀的手动测试员。让我们看看自动化测试如何消除手动测试的缺点,并将更好的开发置于测试的中心。

理解自动化测试

自动化测试简单来说就是使用自动化工具测试软件以查找缺陷的过程。这些自动化工具可以是用于构建应用程序的语言编写的脚本,或者是一些软件或驱动程序(例如SeleniumWinRunnerLoadRunner),以使自动化测试更加容易和快速。

自动化测试解决了手动测试的缺点,并且它还具有更多优点,如下所示:

  • 执行速度更快

  • 从长远来看,比手动测试便宜

  • 更可靠、强大和多功能

  • 在回归测试中非常有用

  • 能够提供更好的测试覆盖率

  • 可能在没有人为干预的情况下运行

  • 更便宜

然而,自动化测试在某些方面也存在不便:

  • 初始成本较高

  • 当需求发生变化时,维护成本非常高

  • 自动化测试工具成本较高

自动化测试和手动测试的真实价值在于它们在正确环境中被使用时。

例如,手动测试在前端项目中非常有用,您想测试可用性和用户体验。自动化测试可以用来测试代码中的方法或函数,并且对于查找错误或安全问题非常有用。

在本章中,我们将专注于用 Python 编写自动化测试。由于我们正在开发一个 API,我们想确保系统是可靠的,并且按照我们的期望运行,但它也应该能够抵御下一个添加的功能可能带来的问题。

说了这么多,让我们来谈谈 Django 中的测试,并介绍测试驱动开发TDD)的概念。

Django 中的测试

在 Python 中进行测试,尤其是在 Django 中,非常简单和容易。实际上,该框架提供了许多工具和实用程序,您可以使用它们来编写应用程序中模型、序列化器或视图的测试。

然而,Python 的测试生态系统在很大程度上依赖于一个工具来编写测试,这个工具与 Django 有深度集成。这个工具叫做Pytest(docs.pytest.org),是一个用于编写小型和可读性测试的框架。与 Django 一起使用时,Pytest 主要用于通过编写代码来测试 API 端点、数据库和用户界面进行 API 测试。

但为什么使用 Pytest?嗯,它有以下优点:

  • 它是免费和开源的

  • 语法简单,易于上手

  • 它自动检测测试文件、函数和类

  • 它可以并行运行多个测试,提高性能和测试运行速度

我们将在本项目中使用 Pytest 编写两种类型的测试:集成测试单元测试

在开始编码之前,让我们通过考虑 TDD 和测试金字塔的概念来学习集成测试和单元测试。

测试金字塔

测试金字塔是一个可以帮助开发者从测试开始创建高质量软件的框架。基本上,测试金字塔指定了应该包含在自动化测试套件中的测试类型。

首先,记住测试金字塔在三个层面上运作:

  • 单元测试

  • 集成测试

  • 端到端测试

下图显示了金字塔中每个层面的位置以及它们在速度性能和集成或隔离程度方面的优先级:

图 5.1 – 测试金字塔

图 5.1 – 测试金字塔

在前面的图中,底层被单元测试占据。单元测试针对单个组件或功能,以检查它们在隔离条件下是否按预期工作。在我们的后端项目中,一个例子是测试User类模型上的like_post方法是否真正按预期执行。我们不是在测试整个User模型;我们是在测试User模型类的一个方法。

写很多单元测试绝对是一个好习惯。它们应该至少占你代码库中所有测试的 60%,因为它们速度快、短小,并且测试了很多组件。

在第二层,你有集成测试。如果单元测试验证代码库的小部分,集成测试则测试这段代码如何与其他代码或其他软件部分交互。集成测试的一个有用但具有争议的例子是编写一个针对视图集的测试。当测试视图集时,你也在测试权限、认证类、序列化器、模型,如果可能的话,还有数据库。这是一个测试 Django API 的不同部分如何协同工作的测试。

集成测试也可以是应用程序与外部服务之间的测试,例如支付 API。

在金字塔顶部的第三层,你有端到端测试。这类测试确保软件按预期工作。它们测试应用程序从开始到结束的工作方式。

在这本书中,我们将重点关注单元测试和集成测试。请注意,集成测试是某些误解的主题,一旦我们定义了它们,这些误解就会消除。根据我的个人经验,Django 中的单元测试更多地写在每个应用程序的模型和序列化器方面。它们可以用来测试在数据库中创建对象,以及检索、更新或删除。

关于视图集测试,我相信它们可以作为集成测试,因为运行它们会调用权限、认证、序列化器、验证,以及根据你执行的操作,还有模型。

返回到单元测试,当使用 TDD(测试驱动开发)时,它们更有效,TDD 是一种软件开发实践,它侧重于在开发功能之前编写单元测试用例。尽管听起来有些反直觉,但 TDD 有很多优点:

  • 它确保代码优化

  • 它确保了设计模式和更好架构的应用

  • 它帮助开发者理解业务需求

  • 它使代码更灵活且易于维护

然而,我们并没有特别遵守书中的 TDD 规则。我们依赖于 Django shell 和客户端来测试我们正在构建的 REST API 的功能。对于项目将要添加的下一个功能,测试将在编码功能之前编写。

在理解了 TDD、单元和集成测试以及测试金字塔等概念之后,我们现在可以配置测试环境。

配置测试环境

单独来看,Pytest 只是一个用于在 Python 程序中编写单元测试的 Python 框架。幸运的是,有一个 Pytest 插件可以用于 Django 项目和应用程序的测试。

使用以下命令安装和配置测试环境:

pip install pytest-django

一旦安装了包,在 Django 项目的根目录下创建一个名为 pytest.ini 的新文件:

pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE=CoreRoot.settings
python_files=tests.py test_*.py *_tests.py

一旦完成,运行 pytest 命令:

pytest

你将看到以下输出:

======================== test session starts ============================
platform linux -- Python 3.10.2, pytest-7.0.1, pluggy-1.0.0
django: settings: CoreRoot.settings (from ini)
rootdir: /home/koladev/PycharmProjects/Full-stack-Django-and-React, configfile: pytest.ini
plugins: django-4.5.2
collected 0 items

太棒了!Pytest 已安装到项目中,我们可以编写项目中的第一个测试来测试配置。

编写你的第一个测试

Pytest 环境已配置,让我们看看如何使用 Pytest 编写一个简单的测试。

在项目根目录下创建一个名为 tests.py 的文件。我们将简单地编写一个测试来测试函数的求和。

遵循 TDD 概念,我们将首先编写测试并使其失败:

tests.py

def test_sum():
   assert add(1, 2) == 3

这个函数是为了检查一个条件,从而证明使用 assert Python 关键字是合理的。如果 assert 后的条件为真,脚本将继续或停止执行。如果不是这种情况,将引发断言错误。

如果你运行 pytest 命令,你会收到以下输出:

图 5.2 – 失败的测试

图 5.2 – 失败的测试

从前面的输出中,我们可以确定测试失败了。现在让我们编写通过测试的功能。

在同一文件 tests.py 中,添加以下函数:

tests.py

def add(a, b):
   return a + b
def test_sum():
   assert sum(1, 2) == 3

现在,在终端中再次运行 pytest 命令。现在一切应该都是绿色的:

图 5.3 – 测试成功通过

图 5.3 – 测试成功通过

太棒了!你已经使用 Pytest 在项目中编写了第一个测试。在下一节中,我们将编写项目的模型测试。

为 Django 模型编写测试

当将测试应用于 Django 项目时,始终从编写模型测试开始是一个好主意。但为什么要测试模型呢?

嗯,这让你对自己的代码和数据库连接更有信心。它将确保模型上的方法或属性在数据库中得到良好表示,但也可以帮助你更好地构建代码结构、解决 bug 和编写文档。

不再拖延,让我们先为User模型编写测试。

为 User 模型编写测试

core/user目录中,创建一个名为tests.py的新文件。我们将编写创建用户和简单用户的测试。

core/user/tests.py

import pytest
from core.user.models import User
data_user = {
   "username": "test_user",
   "email": "test@gmail.com",
   "first_name": "Test",
   "last_name": "User",
   "password": "test_password"
}

一旦添加了导入和创建用户所需的数据,我们就可以编写测试函数:

core/user/tests.py

@pytest.mark.django_db
def test_create_user():
   user = User.objects.create_user(**data_user)
   assert user.username == data_user["username"]
   assert user.email == data_user["email"]
   assert user.first_name == data_user["first_name"]
   assert user.last_name == data_user["last_name"]

test_create_user函数上方,你可能注意到了一些语法。这被称为装饰器,它基本上是一个接受另一个函数作为参数并返回另一个函数的函数。

@pytest.mark.django_db为我们提供了访问 Django 数据库的权限。尝试移除此装饰器并运行测试。

你将在最后得到一个包含类似消息的错误输出:

=================================================================== short test summary info ===================================================================
FAILED core/user/tests.py::test_create_user - RuntimeError: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixture...

嗯,重新添加装饰器并运行pytest命令,所有测试应该都能正常通过。

让我们再进行一个测试,以确保superuser的创建工作完美无误。

添加一个新的字典,包含创建superuser所需的数据:

core/user/tests.py

data_superuser = {
   "username": "test_superuser",
   "email": "testsuperuser@gmail.com",
   "first_name": "Test",
   "last_name": "Superuser",
   "password": "test_password"
}

这里是测试superuser创建功能的函数:

core/user/tests.py

@pytest.mark.django_db
def test_create_superuser():
   user = User.objects.create_superuser(**data_superuser)
   assert user.username == data_superuser["username"]
   assert user.email == data_superuser["email"]
   assert user.first_name == data_superuser["first_name"]
   assert user.last_name == data_superuser["last_name"]
   assert user.is_superuser == True
   assert user.is_staff == True

再次运行测试,一切应该都是绿色的。

太棒了!现在我们更好地理解了pytest在测试中的作用,让我们为Post模型编写测试。

为 Post 模型编写测试

要创建一个模型,我们需要一个用户对象。对于Comment模型也是如此。为了避免重复,我们将简单地编写** fixtures**。

fixture 是一个函数,它将在应用到的每个测试函数之前运行。在这种情况下,fixture 将用于向测试提供一些数据。

要在项目中添加 fixture,请在core目录中创建一个新的 Python 包,名为fixtures

core/fixtures目录中,创建一个名为user.py的文件。该文件将包含一个用户 fixture:

core/fixtures/user.py

import pytest
from core.user.models import User
data_user = {
   "username": "test_user",
   "email": "test@gmail.com",
   "first_name": "Test",
   "last_name": "User",
   "password": "test_password"
}
@pytest.fixture
def user(db) -> User:
   return User.objects.create_user(**data_user)

在前面的代码中,@pytest.fixture装饰器将函数标记为 fixture。现在我们可以在任何测试中导入user函数,并将其作为参数传递给test函数。

core/post目录中,创建一个名为tests.py的新文件。然后该文件将测试创建帖子。

这里是代码:

core/post/tests.py

import pytest
from core.fixtures.user import user
from core.post.models import Post
@pytest.mark.django_db
def test_create_post(user):
   post = Post.objects.create(author=user,
                              body="Test Post Body")
   assert post.body == "Test Post Body"
   assert post.author == user

正如你所见,我们在fixtures目录中从user.py导入user函数,并将其作为参数传递给test_create_post测试函数。

运行pytest命令,一切应该都是绿色的。

现在我们已经为Post模型编写了工作测试,让我们为Comment模型编写测试。

为 Comment 模型编写测试

Comment模型编写测试需要与Post模型的测试相同的步骤。首先,在core/fixtures目录中创建一个名为post.py的新文件。

此文件将包含一个帖子固定设置,因为它需要创建一个评论。

但是post固定设置也需要一个user固定设置。幸运的是,使用 Pytest 可以将固定设置注入到其他固定设置中。

下面是post固定设置的代码:

core/fixtures/post.py

import pytest
from core.fixtures.user import user
from core.post.models import Post
@pytest.fixture
def post(db, user):
   return Post.objects.create(author=user,
                              body="Test Post Body")

太好了!添加了固定设置后,我们现在可以编写评论创建的测试。

core/comment/目录内,创建一个名为tests.py的新文件:

core/comment/tests.py

import pytest
from core.fixtures.user import user
from core.fixtures.post import post
from core.comment.models import Comment
@pytest.mark.django_db
def test_create_comment(user, post):
   comment = Comment.objects.create(author=user, post=post,
     body="Test Comment Body")
   assert comment.author == user
   assert comment.post == post
   assert comment.body == "Test Comment Body"

使用pytest命令运行测试,一切应该都是绿色的。

太好了!我们刚刚为项目中的所有模型编写了测试。让我们继续编写视图集的测试。

为 Django 视图集编写测试

视图集或端点是外部客户端将用来获取数据以及创建、修改或删除数据的业务逻辑接口。始终有一个测试来确保整个系统从数据库请求开始按预期工作是一个很好的习惯。

在开始编写测试之前,让我们配置 Pytest 环境以使用 DRF 的 API 客户端。

API 客户端是一个类,它处理不同的 HTTP 方法,以及测试中的认证等特性,这可以直接认证而不需要用户名和密码来测试一些端点,非常有帮助。Pytest 提供了一种在测试环境中添加配置的方法。

在项目的根目录下创建一个名为conftest.py的文件。在文件内部,我们将为我们的自定义客户端创建一个固定设置函数:

conftest.py

import pytest
from rest_framework.test import APIClient
@pytest.fixture
def client():
   return APIClient()

太好了!我们现在可以直接在下一个测试中调用这个客户端。

让我们从测试认证端点开始。

编写认证测试

core/auth目录内,创建一个名为tests.py的文件。我们不是直接编写测试函数,而是编写一个包含测试方法的类,如下所示:

core/auth/tests.py

import pytest
from rest_framework import status
from core.fixtures.user import user
class TestAuthenticationViewSet:
   endpoint = '/api/auth/'

让我们在TestAuthenticationViewSet类中添加test_login方法:

Core/auth/tests.py

...
   def test_login(self, client, user):
       data = {
           "username": user.username,
           "password": "test_password"
       }
       response = client.post(self.endpoint + "login/",
                              data)
       assert response.status_code == status.HTTP_200_OK
       assert response.data['access']
       assert response.data['user']['id'] ==
         user.public_id.hex
       assert response.data['user']['username'] ==
         user.username
       assert response.data['user']['email'] == user.email
  ...

此方法基本上测试登录端点。我们使用在conftest.py文件中初始化的客户端固定设置来发出post请求。然后,我们测试响应的status_code值和返回的响应。

运行pytest命令,一切应该都是绿色的。

让我们为registerrefresh端点添加测试:

core/auth/tests.py

...
   @pytest.mark.django_db
   def test_register(self, client):
       data = {
           "username": "johndoe",
           "email": "johndoe@yopmail.com",
           "password": "test_password",
           "first_name": "John",
           "last_name": "Doe"
       }
       response = client.post(self.endpoint + "register/",
                              data)
       assert response.status_code ==
         status.HTTP_201_CREATED
   def test_refresh(self, client, user):
      data = {
           "username": user.username,
           "password": "test_password"
       }
       response = client.post(self.endpoint + "login/",
                                data)
       assert response.status_code == status.HTTP_200_OK
       data_refresh = {
           "refresh":  response.data['refresh']
       }
       response = client.post(self.endpoint + "refresh/",
                              data_refresh)
       assert response.status_code == status.HTTP_200_OK
       assert response.data['access']

在前面的代码中,在test_refresh方法内部,我们登录以获取刷新令牌,以便发出请求以获取新的访问令牌。

再次运行pytest命令来运行测试,一切应该都是绿色的。

让我们继续编写PostViewSet的测试。

编写 PostViewSet 的测试

在开始编写视图集测试之前,让我们快速重构代码以简化测试并遵循 DRY 规则。在core/post目录中,创建一个名为tests的 Python 包。完成后,将core/post目录中的tests.py文件重命名为test_models.py并将其移动到core/post/tests/目录。

在同一目录下,创建一个名为test_viewsets.py的新文件。此文件将包含对PostViewSet的测试:

core/post/tests/test_viewsets.py

from rest_framework import status
from core.fixtures.user import user
from core.fixtures.post import post
class TestPostViewSet:
   endpoint = '/api/post/'

PostViewSet处理两种类型用户的请求:

  • 认证用户

  • 匿名用户

每种类型的用户对post资源有不同的权限。因此,让我们确保这些情况得到处理:

core/post/tests/test_viewsets.py

...
   def test_list(self, client, user, post):
       client.force_authenticate(user=user)
       response = client.get(self.endpoint)
       assert response.status_code == status.HTTP_200_OK
       assert response.data["count"] == 1
   def test_retrieve(self, client, user, post):
       client.force_authenticate(user=user)
       response = client.get(self.endpoint +
                             str(post.public_id) + "/")
       assert response.status_code == status.HTTP_200_OK
       assert response.data['id'] == post.public_id.hex
       assert response.data['body'] == post.body
       assert response.data['author']['id'] ==
         post.author.public_id.hex

对于这些测试,我们正在强制进行认证。我们想确保认证用户可以访问帖子的资源。现在让我们为帖子创建、更新和删除编写一个测试方法:

core/post/tests/test_viewsets.py

...
   def test_create(self, client, user):
       client.force_authenticate(user=user)
       data = {
           "body": "Test Post Body",
           "author": user.public_id.hex
       }
       response = client.post(self.endpoint, data)
       assert response.status_code ==
         status.HTTP_201_CREATED
       assert response.data['body'] == data['body']
       assert response.data['author']['id'] ==
         user.public_id.hex
   def test_update(self, client, user, post):
       client.force_authenticate(user=user)
       data = {
           "body": "Test Post Body",
           "author": user.public_id.hex
       }
       response = client.put(self.endpoint +
         str(post.public_id) + "/", data)
       assert response.status_code == status.HTTP_200_OK
       assert response.data['body'] == data['body']
   def test_delete(self, client, user, post):
       client.force_authenticate(user=user)
       response = client.delete(self.endpoint +
         str(post.public_id) + "/")
       assert response.status_code ==
         status.HTTP_204_NO_CONTENT

运行测试,结果应该是绿色的。现在,对于匿名用户,我们希望他们以只读模式访问资源,因此他们不能创建、修改或删除资源。让我们测试并验证这些功能:

core/post/tests/test_viewsets.py

...
   def test_list_anonymous(self, client, post):
       response = client.get(self.endpoint)
       assert response.status_code == status.HTTP_200_OK
       assert response.data["count"] == 1
   def test_retrieve_anonymous(self, client, post):
       response = client.get(self.endpoint +
         str(post.public_id) + "/")
       assert response.status_code == status.HTTP_200_OK
       assert response.data['id'] == post.public_id.hex
       assert response.data['body'] == post.body
       assert response.data['author']['id'] ==
         post.author.public_id.hex

运行测试以确保一切正常。之后,让我们测试禁止的方法:

core/post/tests/test_viewsets.py

...
def test_create_anonymous(self, client):
       data = {
           "body": "Test Post Body",
           "author": "test_user"
       }
       response = client.post(self.endpoint, data)
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED
   def test_update_anonymous(self, client, post):
       data = {
           "body": "Test Post Body",
           "author": "test_user"
       }
       response = client.put(self.endpoint +
         str(post.public_id) + "/", data)
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED
   def test_delete_anonymous(self, client, post):
       response = client.delete(self.endpoint +
         str(post.public_id) + "/")
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED

再次运行测试。太好了!我们刚刚为帖子视图集编写了测试。你现在应该对使用视图集进行测试有了更好的理解。

让我们快速编写对CommentViewSet的测试。

为 CommentViewSet 编写测试

在开始编写视图集测试之前,让我们也快速重构编写测试的代码。在core/comment目录中,创建一个名为tests的 Python 包。完成后,将core/post目录中的tests.py文件重命名为test_models.py并将其移动到core/comment/tests/目录。

在同一目录下,创建一个名为test_viewsets.py的新文件。此文件将包含对CommentViewSet的测试。

就像在PostViewSet中一样,我们有两种类型的用户,我们想要为他们的每种权限编写测试用例。

然而,在创建评论之前,我们需要添加评论固定数据。在core/fixtures目录中,创建一个名为comment.py的新文件并添加以下内容:

core/fixtures/comment.py

import pytest
from core.fixtures.user import user
from core.fixtures.post import post
from core.comment.models import Comment
@pytest.fixture
def comment(db, user, post):
   return Comment.objects.create(author=user, post=post,
                                 body="Test Comment Body")

然后,在core/comment/tests/test_viewsets.py内部,首先添加以下内容:

core/comment/tests/test_viewsets.py

from rest_framework import status
from core.fixtures.user import user
from core.fixtures.post import post
from core.fixtures.comment import comment
class TestCommentViewSet:
   # The comment resource is nested under the post resource
   endpoint = '/api/post/'

接下来,让我们向列表中添加测试并作为认证用户检索评论:

core/comment/tests/test_viewsets.py

...
def test_list(self, client, user, post, comment):
       client.force_authenticate(user=user)
       response = client.get(self.endpoint +
         str(post.public_id) + "/comment/")
       assert response.status_code == status.HTTP_200_OK
       assert response.data["count"] == 1
   def test_retrieve(self, client, user, post, comment):
       client.force_authenticate(user=user)
       response = client.get(self.endpoint +
                             str(post.public_id) +
                             "/comment/" +
                             str(comment.public_id) + "/")
       assert response.status_code == status.HTTP_200_OK
       assert response.data['id'] == comment.public_id.hex
       assert response.data['body'] == comment.body
       assert response.data['author']['id'] ==
         comment.author.public_id.hex

通过运行pytest命令确保这些测试通过。下一步是添加对评论创建、更新和删除的测试:

core/comment/tests/test_viewsets.py

...
    def test_create(self, client, user, post):
       client.force_authenticate(user=user)
       data = {
           "body": "Test Comment Body",
           "author": user.public_id.hex,
           "post": post.public_id.hex
       }
       response = client.post(self.endpoint +
         str(post.public_id) + "/comment/", data)
       assert response.status_code ==
         status.HTTP_201_CREATED
       assert response.data['body'] == data['body']
       assert response.data['author']['id'] ==
         user.public_id.hex
   def test_update(self, client, user, post, comment):
       client.force_authenticate(user=user)
       data = {
           "body": "Test Comment Body Updated",
           "author": user.public_id.hex,
           "post": post.public_id.hex
       }
       response = client.put(self.endpoint +
                             str(post.public_id) +
                             "/comment/" +
                             str(comment.public_id) +
                             "/", data)
       assert response.status_code == status.HTTP_200_OK
       assert response.data['body'] == data['body']
   def test_delete(self, client, user, post, comment):
       client.force_authenticate(user=user)
       response = client.delete(self.endpoint +
         str(post.public_id) + "/comment/" +
         str(comment.public_id) + "/")
       assert response.status_code ==
         status.HTTP_204_NO_CONTENT

再次运行测试以确保一切正常。现在,让我们为匿名用户编写测试。

首先,我们需要确保他们可以使用GET方法访问资源:

core/comment/tests/test_viewsets.py

...
   def test_list_anonymous(self, client, post, comment):
       response = client.get(self.endpoint +
                             str(post.public_id) +
                             "/comment/")
       assert response.status_code == status.HTTP_200_OK
       assert response.data["count"] == 1
   def test_retrieve_anonymous(self, client, post,
     comment):
       response = client.get(self.endpoint +
         str(post.public_id) + "/comment/" +
         str(comment.public_id) + "/")
       assert response.status_code == status.HTTP_200_OK

接下来,我们需要确保匿名用户不能创建、更新或删除评论:

core/comment/tests/test_viewsets.py

   def test_create_anonymous(self, client, post):
       data = {}
       response = client.post(self.endpoint +
         str(post.public_id) + "/comment/", data)
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED
   def test_update_anonymous(self, client, post, comment):
       data = {}
       response = client.put(self.endpoint +
         str(post.public_id) + "/comment/" +
         str(comment.public_id) + "/", data)
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED
   def test_delete_anonymous(self, client, post, comment):
       response = client.delete(self.endpoint +
         str(post.public_id) + "/comment/" +
         str(comment.public_id) + "/")
       assert response.status_code ==
         status.HTTP_401_UNAUTHORIZED

在前面的例子中,数据 dict 是空的,因为我们期望错误状态。

再次运行测试以确保一切正常!

哇哦。我们刚刚为 CommentViewSet 编写了测试。我们还需要为 UserViewSet 类编写测试,但这将是一个小项目给你。

为 UserViewSet 类编写测试

在本节中,让我们进行一个快速的实际操作练习。你需要为 UserViewSet 类编写代码。它与我们已经为 PostViewSetCommentViewSet 编写的其他测试非常相似。我已经为你提供了类的结构,你所要做的就是编写方法中的测试逻辑。以下是你需要构建的结构:

core/user/tests/test_viewsets.py

from rest_framework import status
from core.fixtures.user import user
from core.fixtures.post import post
class TestUserViewSet:
   endpoint = '/api/user/'
   def test_list(self, client, user):
       pass
   def test_retrieve(self, client, user):
       pass
   def test_create(self, client, user):
       pass
   def test_update(self, client, user):
       pass

这里是关于测试的要求:

  • test_list:认证用户应该能够列出所有用户

  • test_retrieve:认证用户可以检索与用户相关的资源

  • test_create:用户不能通过 POST 请求直接创建用户

  • test_update:认证用户可以使用 PATCH 请求更新 user 对象

你可以在这里找到这个练习的解决方案:github.com/PacktPublishing/Full-stack-Django-and-React/blob/main/core/user/tests/test_viewsets.py

概述

在本章中,我们学习了测试,不同类型的测试及其优势。我们还介绍了在 Django 中使用 Pytest 进行测试,并为模型和视图集编写了测试。使用 TDD 方法编写测试所获得的能力有助于你更好地设计代码,防止与代码架构相关的错误,并提高软件的质量。别忘了,它们还能在就业市场上给你带来竞争优势。

这是第一部分 技术背景 的最后一章。下一部分将专注于 React 以及将前端连接到我们刚刚构建的 REST API。在下一章中,我们将学习更多关于前端开发和 React 的知识,并创建一个 React 项目并运行它。

问题

  1. 什么是测试?

  2. 单元测试是什么?

  3. 测试金字塔是什么?

  4. 什么是 Pytest?

  5. 什么是 Pytest 固定装置?

第二部分:使用 React 构建响应式 UI

在我们这本书的第一部分中,我们使用 Django 构建了 Postagram 应用程序的后端,包括身份验证功能和帖子及评论管理。在这一部分书中,你将构建一个代表 Postagram UI 界面的 React 应用程序,用户将看到帖子及评论,并能够点赞帖子或评论,上传个人头像,并访问其他用户的个人资料。在这一部分的最后,你将具备使用 React 从前端处理身份验证、从头开始构建 UI 组件、使用 React Hooks 如useStateuseContextuseMemo以及向 REST API 发送请求并处理响应所需的知识。

本节包括以下章节:

  • 第六章使用 React 创建项目

  • 第七章构建登录和注册表单

  • 第八章社交媒体帖子

  • 第九章帖子评论

  • 第十章用户个人资料

  • 第十一章针对 React 组件的有效 UI 测试

第六章:使用 React 创建项目

在本章中,我们将专注于理解前端开发,并使用 React 创建一个 Web 前端项目。在前面的章节中,我们主要关注 Django 和 Django Rest。在本章中,我们将解释前端开发的基础知识。接下来,我们将介绍 React 库,并为后续章节创建一个起始项目。最后,我们将学习如何配置我们的项目。

在本章中,我们将涵盖以下主题:

  • 理解前端开发

  • 创建 React 项目

  • 配置项目

  • 有用的 ES6 和 React 特性

技术要求

在这本书中,我们使用 Linux 操作系统,但您也可以在其他操作系统上找到此项目所需的所有工具。在本章中,我们将介绍如何在您的机器上安装 Node.js 和Visual Studio CodeVS Code)。

以下 GitHub 链接也将是必需的:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap6

理解前端开发

前端开发是软件开发中专注于用户界面UI)的部分。在 Web 开发中,前端开发是指为网站或 Web 应用程序生成HTMLCSSJavaScript的实践。

HTML代表超文本标记语言。HTML 在页面上显示内容,如文本、按钮、链接、标题或列表。

CSS被定义为层叠样式表。CSS 用于美化网页。它处理诸如颜色、布局和动画等问题。它还帮助提高网站的可访问性,以便每个人都能轻松使用您的网站。

最后,JavaScript是一种客户端语言,它促进了用户交互并使页面动态化。它可以帮助进行复杂的动画、表单验证、从服务器获取数据以及将数据提交到服务器。

然而,与 Python 等语言一样,虽然从头开始使用 HTML、CSS 和 JavaScript 构建前端应用程序是完全可能的,但这相当困难。它需要良好的代码架构和组件重用性知识。最终,您将创建自己的开发框架。

但为什么不直接使用一些预构建的 CSS 或 JavaScript 框架呢?

如 Vue、Angular 或 React 之类的工具可以帮助您以种子和更平滑的方式编写前端应用程序。

在这本书中,我们将使用 React 的开源 JavaScript 库。让我们更多地了解 React 作为一个库。

什么是 React?

React是一个库,它帮助开发者通过称为组件的小型可重用部件的树状结构构建响应式 UI。

在前端开发中,组件是由 HTML、CSS 和 JavaScript 混合而成,用于捕获渲染小部分或较大 UI 所需的逻辑。让我们分析以下 HTML 表单,以更好地理解前端开发中的组件:

图 6.1 – HTML 表单

图 6.1 – HTML 表单

如 *图 6**.1 所示,在表单中,我们定义了四个组件:

  • 名称输入

  • 电子邮件输入

  • 消息输入

  • 提交按钮

这些组件中的每一个都有自己的逻辑。例如,提交按钮将验证表单并将消息保存或发送到远程源。

React 被定义为库而不是框架,因为它只处理 UI 渲染,并将许多其他在开发中很重要的事情留给开发者或其他工具。

要构建 React 应用程序,你需要以下堆栈:

  • 应用程序代码:React、Redux、ESLint、Prettier 和 React Router

  • npm/yarn/pnpmbabel

  • 测试工具:Jest 和 Enzyme

你需要将这些依赖项添加到你的 React 项目中,以优化和执行一些任务——这就是 React 与例如带有其自己的路由堆栈的 Angular 等工具不同的地方。

现在我们对 React 有更好的理解,让我们创建一个 React 项目。

创建 React 项目

在创建 React 项目之前,我们需要安装一些工具以获得更好的开发体验。这些工具基本上是驱动程序、编辑器和插件。让我们先安装 Node.js。

安装 Node.js

Node.js 是一个开源且功能强大的基于 JavaScript 的服务器端环境。它允许开发者即使在 JavaScript 本地是客户端语言的情况下,也能在服务器端运行 JavaScript 程序。

Node.js 可用于多个操作系统,如 Windows、macOS 和 Linux。在这本书中,我们正在使用 Linux 机器,Node.js 应该已经默认安装。

对于其他操作系统,你可以在 nodejs.org/en/download/ 找到安装包。为你的操作系统下载最新的 长期支持 (LTS) 版本。

当访问链接时,你将看到一个类似于以下截图的输出:

图 6.2 – Node.js 安装程序

图 6.2 – Node.js 安装程序

要检查 Node.js 是否已安装在你的 Linux 机器上,打开终端并输入以下命令:

node -v
yarn -v

这些命令应该会显示已安装的 Node.js 和 yarn 版本:

图 6.3 – node 和 yarn 版本

图 6.3 – node 和 yarn 版本

如果你机器上没有安装 yarn,你可以使用 npm 包来安装它:

npm install -g yarn

yarnnpm 是 JavaScript 的包管理器。在接下来的章节中,我们将大量使用 yarn 包管理器来安装包、运行测试或构建前端的生产版本。然而,如果你愿意,也可以使用 npm。只是别忘了命令略有不同。

现在已经安装了用于 JavaScript 开发的基本工具。接下来,我们需要安装 VS Code 并配置它以使 JavaScript 开发更容易。

安装 VS Code

VS Code 是由微软开发和维护的开源代码编辑器。它支持多种编程语言,并且通过插件和扩展,你可以轻松地将其转换成一个强大的集成开发环境。然而,你也可以使用其他编辑器,例如AtomBrackets或强大的IDE WebStorm。请随意使用你熟悉的工具。

VS Code 适用于 Windows、macOS 和 Linux,你可以在code.visualstudio.com/下载适合你操作系统的版本。

安装并打开后,你会看到以下窗口:

图 6.4 – VS Code 窗口

图 6.4 – VS Code 窗口

VS Code 内置了一个终端,你可以使用它来创建和运行 React 应用。注意,你还可以在项目目录的终端中使用以下命令打开项目:

code .

你可以在视图 | 集成 终端菜单中找到内置的终端。

在探索了 VS Code 的基础功能后,让我们添加所需的扩展,以使 React 开发更加愉快。

添加 VS Code 扩展

每种编程语言和框架都提供大量扩展,以使开发更加轻松和愉快。这些扩展包括代码片段、测试、项目环境配置和代码格式化。在 VS Code 中,如果你在活动栏(左侧的栏)中打开扩展,你可以找到一个搜索栏来查找不同的扩展。

对于 React 项目,让我们首先添加ES7+ React/Redux/React-Native/JS代码片段扩展。这个扩展会在编写 React 文件时建议代码片段。它看起来可能像这样:

图 6.5 – ES7 + React/Redux/React-Native/JS 代码片段扩展

图 6.5 – ES7 + React/Redux/React-Native/JS 代码片段扩展

之后,让我们安装ESLint扩展。它将通过自动格式化代码并显示格式错误来帮助你快速找到拼写错误和语法错误。这使得 ES 代码格式化规则易于理解。ESLint 扩展看起来可能像这样:

图 6.6 – ESLint 扩展

图 6.6 – ESLint 扩展

接下来,我们将添加另一个名为Prettier的 VS Code 扩展。Prettier 是一个代码格式化工具,它不仅使你的代码看起来更吸引人,而且结构更清晰,便于阅读。你可以在保存代码后找到 VS Code 扩展来自动格式化你的代码。这个扩展看起来可能像这样:

图 6.7 – 更漂亮的代码格式化工具

图 6.7 – 更漂亮的代码格式化工具

最后,但不是必须的,我们有indent-rainbow。如果你有很多带有父亲和子代的代码块,阅读可能会变得相当困难。这个扩展将使具有缩进的 JavaScript 代码更易于阅读。它看起来可能像这样:

图 6.8 – indent-rainbow 扩展

图 6.8 – indent-rainbow 扩展

太好了!在 VS Code 中安装了这些扩展后,我们现在可以继续创建 React 应用程序。

创建和运行一个 React 应用程序

在安装并配置了 Node.js 和 VS Code 之后,我们拥有了创建第一个 React.js 应用程序所需的一切。

要创建我们的 React 应用程序,我们将使用 create-react-app (github.com/facebook/create-react-app),这是一个用于创建现代 Web React 应用程序的简单命令。按照以下步骤创建您的第一个 React 应用程序并修改代码:

  1. 运行以下命令以创建一个 React 应用程序:

    yarn create react-app social-media-app
    

此命令将创建一个名为 social-media-app 的 React 应用程序。如果您使用的是 npm,则将 yarn 替换为 npx。安装后,您将得到类似于以下截图的输出:

图 6.9 – React 项目创建终端输出

图 6.9 – React 项目创建终端输出

social-media-app 目录中,您会找到一个名为 package.json 的文件。此文件包含 JavaScript 项目的所有配置,从项目的基本信息,如名称、版本和开发者,到安装的包列表以及与启动服务器相关的脚本等。

  1. 使用以下命令运行创建的 React 应用程序:

    yarn start
    
  2. 打开您的浏览器,并将 localhost:3000 指定为您网页链接。完成后,它将看起来像这样:

图 6.10 – 运行 React 应用程序

图 6.10 – 运行 React 应用程序

应用程序正在运行。现在,让我们修改 React 应用程序中的代码。

  1. 在 VS Code 编辑器中打开 src 文件夹中的 App.js 文件。

  2. App.js 文件中的文本从 Learn React 修改为 Hello World 并保存文件:

图 6.11 – App.js 代码

图 6.11 – App.js 代码

  1. 再次检查浏览器,您将看到变化:

图 6.12 – 修改后的 React 应用程序

图 6.12 – 修改后的 React 应用程序

React 具有热重载功能,这意味着对项目中文件所做的任何更改都会反映在 Web 应用的渲染中。

太好了!我们刚刚安装了一个 React 应用程序并修改了代码。

让我们在浏览器中安装一些工具来调试 React 应用程序。

在浏览器中安装调试插件

要调试 React 应用程序,我们必须安装 React Developer Tools,这是一个在 Chrome、Firefox 和 Edge 浏览器上可用的插件。您可以在 Chrome 版本的插件地址为 chrome.google.com/webstore/category/extensions,Firefox 版本的插件地址为 addons.mozilla.org。插件看起来大致如下:

图 6.13 – React 浏览器插件

图 6.13 – React 浏览器插件

安装完成后,您可以通过在 Chrome 浏览器中按Ctrl + Shift + I(或F12)打开开发者工具。以下截图显示了 Firefox 浏览器中的开发者工具:

图 6.14 – 带有打开的 React 扩展的 React 应用程序

图 6.14 – 带有打开的 React 扩展的 React 应用程序

此工具在开发阶段查找错误和调试应用程序时将非常有用。

项目已创建,现在可以成功运行。在下一节中,我们将安装和配置一些用于路由和样式的包。

配置项目

在开始编写身份验证流程之前,让我们确保项目已准备好进行编码。在本节中,我们将配置样式和路由,并允许 API 上的请求。

让我们先从路由开始。

添加 React Router

前端应用程序中的路由表示处理从一个视图移动到另一个视图以及使用正确的 URL 加载正确页面的所有内容。

React 没有内置的路由包,因此我们将使用react-router包。

您可以使用以下命令安装包:

yarn add react-router-dom@6

然后,按照如下方式编辑index.js文件:

src/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
// Creating the root application component
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
 <React.StrictMode>
   <BrowserRouter>
     <App />
   </BrowserRouter>
 </React.StrictMode>
);

在前面的代码块中,我们导入了BrowserRouter组件,并将其包裹在React.StrictMode组件中,这有助于我们在开发模式下接收警告,最后,App组件被包裹在BrowserRouter组件中。

配置了 React Router 后,我们可以自由地继续安装 React Bootstrap 进行样式设置。

添加 React Bootstrap

React 可以很容易地与 CSS 框架配置。对于此项目,为了简单和快速开发,我们将选择 Bootstrap。

幸运的是,React 生态系统提供了一个名为react-bootstrap的包,它独立于 JQuery。

运行以下命令安装包:

yarn add react-bootstrap bootstrap

接下来,将bootstrap CSS 文件导入到index.js文件中,如下所示:

src/index.js

...
import 'bootstrap/dist/css/bootstrap.min.css';
import App from "./App";
...

安装了react-routerreact-bootstrap后,让我们在下一小节中创建一个使用这两个包的快速页面。

创建主页

使用 React Router 在 React 中创建页面通常遵循以下模式:

  • 创建组件和页面

  • BrowserRouter中注册页面并指定 URL

按照以下步骤创建Home页面:

  1. src目录下创建一个名为pages的目录。

  2. pages目录中,创建一个名为Home.jsx的新文件:

图 6.15 – 页面文件夹结构

图 6.15 – 页面文件夹结构

此文件将包含Profile页面的 UI。

  1. 将以下文本添加到Home.jsx文件中,以确保身份验证正常工作:

src/pages/Home.jsx

import React from "react";
function Home() {
 return (
   <div>
     <h1>Profile</h1>
     <p>
       Welcome!
     </p>
   </div>
 );
}
export default Home;
  1. App.js文件中注册此页面:

src/App.js

import React from "react";
import { Route, Routes } from "react-router-dom";
import Home from "./pages/Home";
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
    </Routes>
  );
}
export default App;

要使用 React Router 注册页面,您使用 <Route /> 组件并传递两个属性:

  • 路径

  • 组件元素

  1. 添加前面的代码后,确保 React 项目正在运行。您可以在 http://127.0.0.1:3000 的页面进行检查:

图 6.16 – 首页

图 6.16 – 首页

太好了!添加这些后,让我们快速配置 Django 项目,以避免在下一节中遇到一些请求问题。

配置 CORS

CORS 代表 跨源资源共享。它是一种浏览器机制,允许对位于给定域之外的资源进行受控访问。

它有助于防止跨域攻击或不受欢迎的请求。在本项目中,React 项目运行在 http://127.0.0.1:3000

如果我们尝试从浏览器发起一些请求,我们会收到一个错误。打开 http://127.0.0.1:3000 上的 React 应用并打开 开发者工具

图 6.17 – 打开开发者工具

图 6.17 – 打开开发者工具

此外,请确保 Django 服务器正在运行。

在控制台中输入以下行:

fetch("http://127.0.0.1:8000/api/post/")

您将收到一个错误:

图 6.18 – 发起请求时的 CORS 错误

图 6.18 – 发起请求时的 CORS 错误

按照以下步骤快速配置 Django API:

  1. 启用 django-cors-headers

    pip install django-cors-headers
    
  2. 如果 django-cors-headers 包的安装完成,请转到您的 settings.py 文件并将包添加到 INSTALLED_APPS 和中间件中:

CoreRoot/settings.py

INSTALLED_APPS = [
    ...
    'corsheaders',
    ...
]
MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    ...
]
  1. settings.py 文件的末尾添加以下行:

CoreRoot/settings.py

CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://127.0.0.1:3000"
]
  1. 开发者工具 中再次发起请求。

您将看到请求已通过,我们现在一切正常。API 已准备好接受来自 React 应用的请求:

图 6.19 – 在开发者工具中尝试成功的请求

图 6.19 – 在开发者工具中尝试成功的请求

使用配置了后端的 React 项目以获得更好的开发体验后,我们现在可以探索在接下来的章节中会大量使用的 ES6ECMAScript 6)和 React 功能。

有用的 ES6 和 React 功能

JavaScript 和 React 是不断进化的语言和技术,每年都会融入令人兴奋的新特性。ES6,也称为 ECMAScript 2015,是 JavaScript 语言中的一个重大增强,它允许开发者使用更好的技术和模式编写用于复杂应用的程序。

使用 React,我们已经从编写类转向使用函数和 React Hooks 编写组件。在本节中,我们将快速探索在接下来的章节中会使用的 ES6 语法、React 概念和 React Hooks。

const 和 let

const 关键字是在 const 关键字中引入的,变量不能被重新声明或重新赋值。以下是其使用示例:

图 6.20 –  关键字的使用

图 6.20 – const 关键字的用法

另一方面,let 用于声明一个可以重新赋值的新值的变量。当你想要创建一个可以随时间变化的变量时,例如计数器或迭代器,这很有用。以下是一个示例:

let counter = 0;
// This is allowed because counter is not a constant
counter++;

通常,默认使用 const 是一个好习惯,只有在需要重新赋值变量时才使用 let。这可以帮助使你的代码更易读并防止意外的重新赋值。

现在我们已经了解了 constlet 关键字的用法,让我们继续了解 JavaScript 中的模板字符串。

模板字符串

在 JavaScript 中,模板字符串是一种定义包含占位符的字符串值的方式,这些占位符可以用于动态值。它们由反引号 (````) 字符表示,并使用美元符号 ($) 和大括号 ({}) 来在字符串中插入表达式。

这里是一个模板字符串的示例:

const name = 'World;
const message = `Hello, ${name}!`;
console.log(message); // "Hello, World!"

在这个例子中,我们定义了一个名为 message 的模板字符串,其中包含一个用于 name 变量的占位符。当模板字符串被评估时,name 变量会被插入到字符串中,并且生成的字符串会被记录到控制台。

模板字符串提供了一种比使用传统的字符串连接运算符 (+) 更方便、更易读的方式来创建包含动态值的字符串。它们还支持 字符串插值,这意味着你可以将表达式插入到字符串中,以及多行字符串。以下是一个示例:

const a = 10;
const b = 20;
const message = `The sum of ${a} and ${b} is ${a + b}.`;
console.log(message); // "The sum of 10 and 20 is 30."

在前面的例子中,我们定义了一个名为 message 的模板字符串,它包含多个表达式,当模板字符串被评估时,这些表达式会被插入到字符串中。这允许我们创建一个比使用字符串连接更易读、更简洁的包含动态值的字符串。

现在我们已经了解了模板字符串是什么,让我们来解释 React 中的 JSX 样式。

JSX 样式

JSX 是 JavaScript 的语法扩展,允许你编写看起来像 HTML 的 JavaScript 代码。它由 Facebook 作为 React 库的一部分引入,但也可以与其他 JavaScript 库和框架一起使用。以下是一个如何在 React 组件中使用 JSX 的示例:

import React from 'react';
function Component() {
  return (
    <div>
      <h1>Hello, world!</h1>
      <p>This is some text.</p>
    </div>
  );
}

在前面的例子中,我们定义了一个名为 Component 的 React 组件,它返回一些 JSX 代码。JSX 代码看起来像 HTML,但由 React 库转换成 JavaScript,生成 DOM 中的适当元素和属性。

当你编写 JSX 时,你可以在大括号 ({}) 内使用 JavaScript 表达式来将动态值插入到 JSX 代码中。这允许你使用 JSX 轻松创建动态和交互式的用户界面:

import React from 'react';
function Component({ name }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>This is some text.</p>
    </div>
  );
}

在前面的例子中,我们定义了一个名为 Component 的 React 组件,它接受一个名为 name 的属性,并使用 JavaScript 表达式将其插入到 JSX 代码中。这允许我们为用户创建一个动态和个性化的问候语。

现在我们已经了解了 JSX 如何与 React 一起工作,让我们解释一下 props 和 states 的概念。

Props 与 states 的比较

在 React 中,props 和 states 是管理组件数据的不同方式。

Props 是 属性 的简称,用于将数据从父组件传递到子组件。Props 是只读的,这意味着子组件不能修改父组件传递给它的 props:

import React from 'react';
function ParentComponent() {
  return (
    <ChildComponent
      name="John Doe"
      age={25}
    />
  );
}
function ChildComponent({ name, age }) {
  return (
    <p>
      My name is {name} and I am {age} years old.
    </p>
  );
}

在前面的代码中,我们定义了一个名为 ParentComponent 的父组件,它渲染了一个名为 ChildComponent 的子组件,并将两个 props (nameage) 传递给子组件。子组件将这些 props 作为参数接收,并使用它们来渲染组件的内容。因为 props 是只读的,所以子组件不能修改父组件传递给它的 nameage props。

另一方面,状态是一种管理组件数据的方式,可以由组件本身修改。状态是私有的,只能使用特殊的 React 方法(如 useState)进行修改。

以下是一个在 React 组件中修改状态的示例:

import React, { useState } from 'react';
function Counter() {
  // Use useState to manage the state of the counter
  const [count, setCount] = useState(0);
  // Function to increment the counter
  function handleIncrement() {
    setCount(count + 1);
  }
  return (
    <div>
      <p>The counter is at {count}.</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

在前面的代码中,我们定义了一个名为 Counter 的组件,它使用 useState 钩子来管理计数器的状态。useState 钩子返回一个包含两个元素的数组,当前状态值(在本例中为 count)和一个用于更新状态的函数(在本例中为 setCount)。

在组件的渲染方法中,我们显示计数器状态的值,并定义一个按钮,当点击该按钮时,会调用 handleIncrement 函数来更新计数器状态。这会导致组件重新渲染并显示更新后的计数器状态值。

现在我们更好地理解了 props 和 state 之间的区别,让我们更深入地了解 useState 钩子。

重要提示

useState 是 React 中的一个 Hook,它允许您向函数组件添加状态。换句话说,useState 允许您管理组件的状态,这是一个包含有关组件信息并可用于在状态变化时重新渲染组件的对象。

Context API

Context API 是在 React 应用程序中在不同组件之间共享数据的一种方式。它允许您通过组件树传递数据,而无需在每一级手动传递属性。以下是一个在 React 应用程序中使用 Context API 的示例:

// Create a context for sharing data
const Context = React.createContext();
function App() {
  // Set some initial data in the context
  const data = {
    message: 'Hello, world!'
  };
  return (
    // Provide the data to the components inside the
    // Provider
    <Context.Provider value={data}>
      <Component />
    </Context.Provider>
  );
}
function Component() {
  // Use the useContext Hook to access the data in the
  // context
  const context = React.useContext(Context);
  return (
    <p>{context.message}</p>
  );
}

在前面的代码中,我们使用 React.createContext 方法创建一个新的上下文对象,我们称之为 Context。然后,我们通过将顶层组件包裹在一个 Context.Provider 组件中,并将数据作为值属性传递,向上下文中提供一些初始数据。最后,我们在 Component 中使用 useContext 钩子来访问上下文中的数据,并在组件中显示它。

在本书中,我们还将使用另一个 Hook。让我们在下一节解释 useMemo 钩子。

useMemo

useMemo 是 React 中的一个 Hook,它允许你通过缓存昂贵的计算来优化组件的性能。它通过返回一个缓存的值来实现,只有当计算中的一个输入改变时,这个值才会重新计算。

重要提示

记忆化 是计算机编程中用于通过存储昂贵函数调用的结果并返回缓存结果的技巧。当再次给出相同的输入时,这可以用于优化执行许多重复计算的程序。

这里是一个如何使用 useMemo 来优化组件性能的例子:

import React, { useMemo } from 'react';
function Component({ data }) {
  // Use useMemo to memoize the expensive calculation
  const processedData = useMemo(() => {
    // Do some expensive calculation with the data
    return expensiveCalculation(data);
  }, [data]);
  return (
    <div>
      {/* Use the processed data in the component */}
      <p>{processedData.message}</p>
    </div>
  );
}

在前面的代码中,我们使用 useMemo 来缓存我们使用传递给组件的数据属性进行的昂贵计算的结果。因为 useMemo 只在数据属性改变时重新计算值,我们可以避免每次组件重新渲染时都进行昂贵的计算,这可以提高我们应用程序的性能。

在下一章我们将构建的 React 项目中,我们将使用 React 和 React 库提供的 Hooks 来处理表单。让我们更多地了解受控和非受控组件。

处理表单 - 受控组件和非受控组件

受控组件是 React 中的一个组件,它由父组件的状态控制。这意味着输入字段的值由传递给组件的值属性决定,任何对输入的更改都由父组件处理。

这里是一个受控组件的例子:

import React, { useState } from 'react';
function Form() {
  // Use useState to manage the state of the input field
  const [inputValue, setInputValue] = useState('');
  // Function to handle changes to the input field
  function handleChange(event) {
    setInputValue(event.target.value);
  }
  return (
    <form>
      <label htmlFor="name">Name:</label>
      <input
        type="text"
        id="name"
        value={inputValue}
        onChange={handleChange}
      />
    </form>
  );
}

在前面的代码中,我们使用 useState 来管理输入字段的州和 handleChange 函数来更新状态,当输入改变时。因为输入的值由 inputValue 状态变量决定,所以输入被视为一个受控组件。

另一方面,非受控组件是 React 中的一个组件,它内部管理自己的状态。这意味着输入字段的值由传递给组件的 defaultValue 属性决定,任何对输入的更改都由组件本身处理。

这里是一个非受控组件的例子:

import React from 'react';
function Form() {
  // Use a ref to manage the state of the input field
  const inputRef = React.useRef();
  // Function to handle the form submission
  function handleSubmit(event) {
    event.preventDefault();
    // Do something with the input value here
    // For example, you might send the input value to an
    // API or save it to the database
    sendInputValue(inputRef.current.value);
    // Clear the input after submitting
    inputRef.current.value = '';
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name:</label>
      <input
        type="text"
        id="name"
        defaultValue=""
        ref={inputRef}
      />
    </form>
  );
}

在这个例子中,我们使用 ref 来管理输入字段的州和 handleSubmit 函数来处理表单提交。因为输入的值由 defaultValue 属性决定,并由组件内部管理,所以输入被视为一个非受控组件。

在本节中,我们探讨了大多数将在下一章中使用的 React 和 ES6 功能。我们将主要使用 React Hooks、JSX 以及有趣的 ES6 功能,如模板字符串和 let/const 关键字。

摘要

在本章中,我们解释了前端开发,并通过安装Node.jsVS Code创建了一个 React 应用程序。然后,我们使用VS Code对其进行配置,以实现更好的开发。React 也将运行在浏览器中,因此我们安装了一些插件,这将使调试更加容易。

然后,我们开始编写一些代码,为路由、样式和 CORS 配置进行基本配置,以允许对 Django API 进行请求。最后,我们探索了下一章中将使用的 React 和 ES6 功能。

在下一章中,我们将通过构建登录和注册页面来更熟悉 React,同时解释组件驱动开发。

问题

  1. Node.js 和yarn是什么?

  2. 前端开发是什么?

  3. 你如何安装 Node.js?

  4. VS Code 是什么?

  5. 你如何在 VS Code 中安装扩展?

  6. 热重载的目的是什么?

  7. 你如何使用create-react-app创建一个 React 应用程序?

第七章:构建“登录”和“注册”表单

注册和登录是具有用户的 Web 应用程序的基本功能。即使身份验证流程可以直接通过简单的请求处理,也需要在 UI 背后有逻辑来管理身份验证和会话,尤其是如果我们使用的是JSON Web Token(JWT)。

在本章中,我们将使用 React 创建登录和注册表单。这里有很多事情要做和学习,以下是本章将涵盖的内容:

  • 在 React 项目中配置 CSS 框架

  • 向应用程序添加受保护和公开的页面

  • 创建注册页面

  • 创建登录页面

  • 登录或注册成功后创建欢迎页面

到本章结束时,你将能够使用 React 构建注册和登录页面,并且将了解如何从前端管理 JWT 身份验证。

技术要求

确保你的机器上已安装并配置了 VS Code。

你可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap7找到本章的代码。

理解身份验证流程

我们已经在第二章“使用 JWT 进行身份验证和授权”中从后端的角度探讨了社交媒体项目上的身份验证。但在 React 应用程序中它是如何体现的呢?

好吧,事情会有一点不同。为了快速回顾,我们有一个注册和登录端点。这些端点返回包含两个令牌的用户对象:

  • 具有 5 分钟有效期的访问令牌:此令牌有助于在服务器端进行请求时进行身份验证,无需再次登录。然后,我们可以访问资源并在这些资源上执行操作。

  • 刷新令牌:此令牌可以帮助你在令牌已过期的情况下检索另一个访问令牌。

使用从服务器返回的数据,我们可以像这样从 React 应用程序端管理身份验证。当注册或登录成功时,我们将返回的响应存储在客户端的浏览器中;我们将使用localStorage来做这件事。

localStorage属性帮助我们与浏览器存储一起工作,使浏览器能够将键值对存储在浏览器中。我们将使用localStorage的两个方法:setItem()用于设置键值对,getItem()用于访问值。

然后,对于发送到服务器的每个请求,我们都会在请求中添加包含从localStorage检索到的访问令牌的“授权”头。如果请求返回401错误,这意味着令牌已过期。如果发生这种情况,我们将向刷新端点发送请求以获取新的访问令牌,同时使用也从localStorage检索到的刷新令牌。然后,我们使用这个访问令牌重新发送失败的请求。

如果我们再次收到401错误,这意味着刷新令牌已过期。然后,用户将被发送到登录页面重新登录,获取新的令牌,并将它们存储在localStorage中。

既然我们已经从前端方面理解了认证流程,那么让我们编写我们将用于数据获取和执行 CRUD 操作的请求服务。

编写请求服务

在 JavaScript 中发起请求相对简单。Node 环境和浏览器提供了原生的包,如fetch,允许你请求服务器。然而,本项目将使用axios包进行 HTTP 请求。

Axios 是一个流行的库,主要用于向 REST 端点发送异步 HTTP 请求。Axios 是 CRUD 操作的理想库。然而,我们还将安装axios-auth-refresh。这个简单的库通过axios拦截器帮助自动刷新令牌。要安装axiosaxios-auth-refresh包,请按照以下步骤操作:

  1. social-media-app目录中,通过运行以下命令添加axiosaxios-auth-refresh包:

    yarn add axios axios-auth-refresh
    
  2. 安装完成后,在 React 项目的src文件夹中创建一个名为helpers的目录,完成后,添加一个名为axios.js的文件:

图 7.1 – helper.js 文件路径

图 7.1 – helper.js 文件路径

现在,让我们进行导入并编写基本配置,例如 URL 和一些头部信息。看看以下代码块:

import axios from "axios";
import createAuthRefreshInterceptor from "axios-auth-refresh";
const axiosService = axios.create({
 baseURL: "http://localhost:8000",
 headers: {
   "Content-Type": "application/json",
 },
});

在前面的代码块中,我们为POST请求添加了Content-Type头部。以下图显示了本书中我们将遵循的认证流程:

图 7.2 – 使用访问/刷新令牌的认证流程

图 7.2 – 使用访问/刷新令牌的认证流程

在前面的图中,请注意以下要点:

  • 每次我们使用axiosService进行请求时,我们从localStorage中检索访问令牌,并使用访问令牌创建一个新的授权头

  • 访问令牌将在请求被发送且返回400状态码时过期

  • 我们从localStorage中检索刷新令牌,并发送请求以获取新的访问令牌

  • 完成后,我们在localStorage中注册新的访问令牌,并重新启动之前失败的请求

  • 然而,如果刷新令牌请求也失败了,我们只需从localStorage中移除auth,并将用户发送到登录屏幕

让我们按照以下步骤在axios.js文件中实现之前描述的流程:

  1. 首先,我们将编写一个请求拦截器来添加头部到请求:

    axiosService.interceptors.request.use(async (config) => {
    
     /**
    
      * Retrieving the access token from the localStorage
    
        and adding it to the headers of the request
    
      */
    
     const { access } =
    
       JSON.parse(localStorage.getItem("auth"));
    
     config.headers.Authorization = `Bearer ${access}`;
    
     return config;
    
    });
    

注意,我们可以在 JavaScript 中使用对象解构语法从对象中提取属性值。在 ES2015 之前的代码中,它可能看起来像这样:

var fruit = {
 name: 'Banana',
 scientificName: 'Musa'
};
var name     = fruit.name;
var scientificName = fruit.scientificName;

如果你需要从一个对象中提取很多属性,它可能会很快变得很长。这就是对象解构派上用场的地方:

var fruit = {
 name: 'Banana',
 scientificName: 'Musa'
};
var  { name, scientificName } = fruit;

你可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment了解更多关于语法的知识。

  1. 之后,我们将解析请求并返回解析或拒绝的承诺:

    axiosService.interceptors.response.use(
    
     (res) => Promise.resolve(res),
    
     (err) => Promise.reject(err),
    
    );
    
  2. 最后这一步是锦上添花。创建一个包含刷新auth逻辑的函数。当失败的请求返回401错误时,这个函数将被调用:

    const refreshAuthLogic = async (failedRequest) => {
    
     const { refresh } =
    
       JSON.parse(localStorage.getItem("auth"));
    
     return axios
    
       .post("/refresh/token/", null, {
    
         baseURL: "http://localhost:8000",
    
         headers: {
    
           Authorization: `Bearer ${refresh}`,
    
         },
    
       })
    
       .then((resp) => {
    
         const { access, refresh } = resp.data;
    
         failedRequest.response.config.headers[
    
           "Authorization"] = "Bearer " + access;
    
         localStorage.setItem("auth", JSON.stringify({
    
                               access, refresh }));
    
       })
    
       .catch(() => {
    
         localStorage.removeItem("auth");
    
       });
    
    };
    
  3. 最后,初始化认证拦截器并创建一个自定义的 fetcher:

    createAuthRefreshInterceptor(axiosService, refreshAuthLogic);
    
    export function fetcher(url) {
    
     return axiosService.get(url).then((res) => res.data);
    
    }
    
    export default axiosService;
    

将使用 fetcher 在 API 资源上执行GET请求。太好了!获取逻辑已实现,我们可以继续注册用户。但在那之前,我们需要在项目中定义受保护的路由。

受保护的路由

在前端应用程序上基于条件的路由是一个很大的优点,因为它有助于提升用户体验。例如,如果你没有登录 Twitter,想要查看个人资料或评论,你将被重定向到登录页面。这些是受保护页面或操作,因此你必须登录才能访问这些资源。在本节中,我们将使用React-Router组件编写一个ProtectedRoute组件。

创建受保护的路由包装器

要创建受保护的路由包装器,请按照以下步骤操作:

  1. src目录中创建一个名为routes的新目录。

  2. 在新建的目录中,创建一个名为ProtectedRoute.jsx的文件。

  3. 文件创建完成后,导入所需的库:

src/routes/ProtectedRoute.jsx

import React from "react";
import { Navigate } from "react-router-dom";
...
  1. 为受保护的路由编写以下逻辑:

    ...
    
    function ProtectedRoute({ children }) {
    
     const { user } =
    
       JSON.parse(localStorage.getItem("auth"));
    
     return auth.account ? <>{children}</> : <Navigate
    
       to="/login/" />;
    
    }
    
    export default ProtectedRoute;
    
    ...
    

在前面的代码片段中,我们从localStorage中检索用户属性。

我们然后使用这个属性来检查是否应该将用户重定向到登录页面或渲染页面(children)。如果user为 null 或 undefined,这意味着用户尚未登录,因此我们将用户重定向到登录页面,否则,我们允许访问请求的页面。

  1. 然后,在App.js文件中,让我们重写内容:

src/App.js

import React from "react";
import {
 Route,
 Routes
} from "react-router-dom";
import ProtectedRoute from "./routes/ProtectedRoute";
import Home from "./pages/Home";
function App() {
 return (
   <Routes>
     <Route path="/" element={
       <ProtectedRoute>
         <Home />
       </ProtectedRoute>
     } />
     <Route path="/login/" element={<div>Login</div>} />
   </Routes>
 );
}
export default App;

现在,默认位置将是个人资料页面。然而,由于存储中没有凭证,用户将被重定向到登录页面。

太好了!我们现在已经实现了认证流程的第一步。在下一节中,我们将编写注册页面,然后再编写登录页面。

创建注册页面

如果用户需要登录凭证,他们首先需要注册。在本节中,我们将创建一个注册表单,同时处理必要的请求。

添加注册页面

让我们从编写表单页面的代码开始。我们将从编写注册form组件开始:

  1. src目录中,创建一个名为components的新目录,然后在新建的目录中创建一个名为authentication的新目录。

此目录将包含注册和登录表单。

  1. 一旦完成,在 authentication 目录下创建一个名为 RegistrationForm.jsx 的文件:

图 7.3 – 注册文件

图 7.3 – 注册文件

React Bootstrap 提供了 form 组件,我们可以快速使用它们创建表单并进行基本验证。在这个组件中,我们还需要向 API 发送请求,将用户详情和令牌注册到存储中,如果请求成功则将用户重定向到主页。

  1. 接下来,我们将添加所需的导入:

src/components/forms/RegistrationForm.js

import React, { useState } from "react";
import { Form, Button } from "react-bootstrap";
import axios from "axios";
import { useNavigate } from "react-router-dom";
...
  1. 现在,声明我们在组件中使用的状态和函数:

src/components/forms/RegistrationForm.js

...
function RegistrationForm() {
 const navigate = useNavigate();
 const [validated, setValidated] = useState(false);
 const [form, setForm] = useState({});
 const [error, setError] = useState(null);
...

让我们快速解释一下前面代码片段中我们在做什么。

navigate 钩子将帮助我们如果请求成功则导航到主页。

validatedformerror 状态分别用于检查表单是否有效,表单中每个字段的值,以及如果请求未通过要显示的错误信息。

  1. 太好了!让我们编写处理表单提交的函数:

src/components/forms/RegistrationForm.js

...
const handleSubmit = (event) => {
   event.preventDefault();
   const registrationForm = event.currentTarget;
   if (registrationForm.checkValidity() === false) {
     event.stopPropagation();
   }
   setValidated(true);
   const data = {
     username: form.username,
     password: form.password,
     email: form.email,
     first_name: form.first_name,
     last_name: form.last_name,
     bio: form.bio,
   };
 ...
  1. 下一步是使用 axios 向 API 发送 POST 请求:

src/components/forms/RegistrationForm.js

   axios
     .post("http://localhost:8000/api/auth/register/",
            data)
     .then((res) => {
       // Registering the account and tokens in the
       // store
       localStorage.setItem("auth", JSON.stringify({
         access: res.data.access,
         refresh: res.data.refresh,
         user: res.data.user,
       }));
       navigate("/");
     })
     .catch((err) => {
       if (err.message) {
         setError(err.request.response);
       }
     });
 };

在前面的代码块中,我们首先使用 event.preventDefault() 阻止默认的表单提交行为——即重新加载页面。接下来,我们检查字段的基本验证是否完成。验证成功后,我们可以轻松地使用 axios 发送请求,并将令牌和用户详情存储在 localStorage 中。

这样,用户就会被导航到主页。

  1. 现在,让我们添加基本的 UI 组件:

src/components/forms/RegistrationForm.js

...
return (
   <Form
     id="registration-form"
     className="border p-4 rounded"
     noValidate
     validated={validated}
     onSubmit={handleSubmit}
   >
     <Form.Group className="mb-3">
       <Form.Label>First Name</Form.Label>
       <Form.Control
         value={form.first_name}
         onChange={(e) => setForm({ ...form,
           first_name: e.target.value })}
         required
         type="text"
         placeholder="Enter first name"
       />
       <Form.Control.Feedback type="invalid">
         This file is required.
       </Form.Control.Feedback>
     </Form.Group>
...

此后有更多的代码,但让我们首先掌握这里的逻辑;其他部分将会容易得多。

React Bootstrap 提供了一个 Form 组件,我们可以用它来创建字段。

Form.Control 是一个组件输入,它接受任何输入都可以接受的属性(nametype 等)。Form.Control.Feedback 当字段无效时会显示错误。

  1. 让我们对 last_nameusername 字段做同样的处理:

src/components/forms/RegistrationForm.js

...
     <Form.Group className="mb-3">
       <Form.Label>Last name</Form.Label>
       <Form.Control
         value={form.last_name}
         onChange={(e) => setForm({ ...form,
           last_name: e.target.value })}
         required
         type="text"
         placeholder="Enter last name"
       />
       <Form.Control.Feedback type="invalid">
         This file is required.
       </Form.Control.Feedback>
     </Form.Group>
     <Form.Group className="mb-3">
       <Form.Label>Username</Form.Label>
       <Form.Control
         value={form.username}
         onChange={(e) => setForm({ ...form, username:
           e.target.value })}
         required
         type="text"
         placeholder="Enter username"
       />
       <Form.Control.Feedback type="invalid">
         This file is required.
       </Form.Control.Feedback>
     </Form.Group>
...
  1. 让我们再添加一个电子邮件字段:

src/components/forms/RegistrationForm.js

...
     <Form.Group className="mb-3">
       <Form.Label>Email address</Form.Label>
       <Form.Control
         value={form.email}
         onChange={(e) => setForm({ ...form, email:
           e.target.value })}
         required
         type="email"
         placeholder="Enter email"
       />
       <Form.Control.Feedback type="invalid">
         Please provide a valid email.
       </Form.Control.Feedback>
     </Form.Group>
...
  1. 让我们再添加一个密码字段:

    ...
    
         <Form.Group className="mb-3">
    
           <Form.Label>Password</Form.Label>
    
           <Form.Control
    
             value={form.password}
    
             minLength="8"
    
             onChange={(e) => setForm({ ...form, password:
    
               e.target.value })}
    
             required
    
             type="password"
    
             placeholder="Password"
    
           />
    
           <Form.Control.Feedback type="invalid">
    
             Please provide a valid password.
    
           </Form.Control.Feedback>
    
         </Form.Group>
    
    ...
    
  2. 让我们添加生物字段。在这里我们将使用 Textarea 字段类型:

src/components/forms/RegistrationForm.js

...
     <Form.Group className="mb-3">
       <Form.Label>Bio</Form.Label>
       <Form.Control
         value={form.bio}
         onChange={(e) => setForm({ ...form, bio:
           e.target.value })}
         as="textarea"
         rows={3}
         placeholder="A simple bio ... (Optional)"
       />
     </Form.Group>
...
  1. 最后,添加提交按钮并导出组件:

src/components/forms/RegistrationForm.js

...
     <div className="text-content text-danger">
         {error && <p>{error}</p>}
     </div>
     <Button variant="primary" type="submit">
       Submit
     </Button>
   </Form>
 );
}
export default RegistrationForm;

RegistrationForm 现在已创建,包含所需的字段和处理表单提交的逻辑。

在下一节中,我们将把这个注册表单组件添加到一个页面中,并在我们的应用程序路由中注册这个页面。

注册注册页面路由

按照以下步骤注册注册页面路由:

  1. src/pages 目录下,创建一个名为 Registration.jsx 的文件:

src/pages/Registration.js

import React from "react";
import { Link } from "react-router-dom";
import RegistrationForm from "../components/forms/RegistrationForm";
function Registration() {
 return (
   <div className="container">
     <div className="row">
       <div className="col-md-6 d-flex align-items-center">
         <div className="content text-center px-4">
           <h1 className="text-primary">
             Welcome to Postman!
           </h1>
           <p className="content">
             This is a new social media site that will
             allow you to share your thoughts and
             experiences with your friends. Register now
             and start enjoying! <br />
             Or if you already have an account, please{" "}
             <Link to="/login/">login</Link>.
           </p>
         </div>
       </div>
       <div className="col-md-6 p-5">
         <RegistrationForm />
       </div>
     </div>
   </div>
 );
}
export default Registration;

我们已经向页面添加了简单的介绍性文本,并导入了 LoginForm 组件。

  1. 接下来,打开 App.js 并注册页面:

src/App.js

...
import Registration from "./pages/Registration";
function App() {
 return (
   <Routes>
     ...
     <Route path="/register/" element={<Registration />} />
   </Routes>
 );
}
...
  1. 太好了!现在,前往 http://localhost:3000/register/,你应该会有与此类似的结果:

图 7.4 – 注册页面

图 7.4 – 注册页面

  1. 测试它并使用一个账户注册。你将被重定向到主页:

图 7.5 – 主页

图 7.5 – 主页

太好了!我们刚刚写完了注册页面。

在下一节中,我们将创建登录页面。

创建登录页面

由于我们已经创建了注册页面,登录逻辑将非常相似,但字段较少。

添加登录页面

按照以下步骤添加登录页面:

  1. src/components/authentication 目录内,添加一个名为 LoginForm.jsx 的新文件。此文件将包含用于登录用户的表单组件。

  2. 接下来,添加导入:

src/components/authentication/LoginForm.jsx

import React, { useState } from "react";
import { Form, Button } from "react-bootstrap";
import axios from "axios";
import { useNavigate } from "react-router-dom";
...
  1. 编写处理登录的逻辑:

src/components/authentication/LoginForm.jsx

...
function LoginForm() {
 const navigate = useNavigate();
 const [validated, setValidated] = useState(false);
 const [form, setForm] = useState({});
 const [error, setError] = useState(null);
 const handleSubmit = (event) => {
   event.preventDefault();
   const loginForm = event.currentTarget;
   if (loginForm.checkValidity() === false) {
     event.stopPropagation();
   }
   setValidated(true);
   const data = {
     username: form.username,
     password: form.password,
   };
...
  1. 正如我们在注册过程中所做的那样,我们现在将对登录端点发起请求:

src/components/authentication/LoginForm.jsx

...
   axios
     .post("http://localhost:8000/api/auth/login/",
            data)
     .then((res) => {
       // Registering the account and tokens in the
       // store
       localStorage.setItem("auth", JSON.stringify({
         access: res.data.access,
         refresh: res.data.refresh,
         user: res.data.user,
       }));
       navigate("/");
     })
     .catch((err) => {
       if (err.message) {
         setError(err.request.response);
       }
     });
...

这几乎与注册逻辑相同,但在这里,我们只处理用户名和密码。

  1. 当准备好处理登录请求的逻辑后,让我们添加 UI:

src/components/authentication/LoginForm.jsx

...
return (
   <Form
     id="registration-form"
     className="border p-4 rounded"
     noValidate
     validated={validated}
     onSubmit={handleSubmit}
   >
     <Form.Group className="mb-3">
       <Form.Label>Username</Form.Label>
       <Form.Control
         value={form.username}
         onChange={(e) => setForm({ ...form, username:
                    e.target.value })}
         required
         type="text"
         placeholder="Enter username"
       />
       <Form.Control.Feedback type="invalid">
         This file is required.
       </Form.Control.Feedback>
     </Form.Group>
     ...

在前面的代码中,我们正在创建表单并添加表单的第一个输入项,即用户名输入。

  1. 让我们再添加密码表单输入和提交按钮:

         ...
    
         <Form.Group className="mb-3">
    
           <Form.Label>Password</Form.Label>
    
           <Form.Control
    
             value={form.password}
    
             minLength="8"
    
             onChange={(e) => setForm({ ...form, password:
    
                        e.target.value })}
    
             required
    
             type="password"
    
             placeholder="Password"
    
           />
    
           <Form.Control.Feedback type="invalid">
    
             Please provide a valid password.
    
           </Form.Control.Feedback>
    
         </Form.Group>
    
         <div className="text-content text-danger">
    
           {error && <p>{error}</p>}</div>
    
         <Button variant="primary" type="submit">
    
           Submit
    
         </Button>
    
       </Form>
    
     );
    
    }
    
    export default LoginForm;
    
    ...
    

我们已经创建了包含所需字段和处理数据提交逻辑的 LoginForm 组件。

在下一节中,我们将向页面添加 LoginForm 并在应用程序的路由中注册此页面。

注册登录页面

按照以下步骤注册登录页面:

  1. src/pages 目录内,创建一个名为 Login.jsx 的文件:

src/pages/Login.jsx

import React from "react";
import { Link } from "react-router-dom";
import LoginForm from "../components/forms/LoginForm";
...
  1. 接下来,让我们添加 UI:

src/pages/Login.jsx

...
function Login() {
 return (
   <div className="container">
     <div className="row">
       <div className="col-md-6 d-flex
         align-items-center">
         <div className="content text-center px-4">
           <h1 className="text-primary">
             Welcome to Postagram!</h1>
           <p className="content">
             Login now and start enjoying! <br />
             Or if you don't have an account, please{" "}
             <Link to="/register/">register</Link>.
           </p>
         </div>
       </div>
       <div className="col-md-6 p-5">
         <LoginForm />
       </div>
     </div>
   </div>
 );
}
export default Login;

这也与注册页面非常相似。

  1. App.js 文件中,将页面注册到应用程序的路由中:

src/App.js

...
     <Route path="/login/" element={<Login />} />
...
  1. 访问 http://localhost:3000/login/,你应该会有与此类似的页面:

图 7.6 – 登录页面

图 7.6 – 登录页面

  1. 再次测试它,你应该会被重定向到主页。

认证流程正在顺利工作,但我们在项目中有一些重复的代码。让我们在下一节中通过一个小练习进行重构。

重构认证流程代码

为了避免在代码库中重复相同的代码,我们可以遵循 LoginFormRegistrationForm 组件。在本节中,我们将编写一个自定义的 React Hook 来处理这个逻辑,但在做之前,让我们了解一下什么是 Hook。

什么是 Hook?

Hooks 首次在 React 16.8 中引入,允许开发者使用更多 React 功能而无需编写类。React Hook 的一个有趣示例是 useState

useStatesetState 的替代品,用于在函数组件内部管理组件的内部状态。在 LoginForm 中,我们使用 useState 来处理表单值。我们还使用 useState 来设置错误消息,如果登录请求返回错误。为了简单测试,请访问登录页面并输入错误的凭据,你可能会得到类似于以下错误:

图 7.7 – 登录表单

图 7.7 – 登录表单

此逻辑来自以下 LoginForm.jsx 中的几行:

src/authentication/LoginForm.jsx

 const [error, setError] = useState(null);
...
     .catch((err) => {
       if (err.message) {
         setError(err.request.response);
       }
     });

这是一个 useState Hook 的示例,并不是每个 Hook 都以相同的方式工作。例如,你可以在 LoginForm 组件中检查 useNavigate Hook 的用法。根据 React 文档,使用 Hooks 有一些规则:

  • 仅在最顶层调用 Hooks:不要在循环、条件或嵌套路由中调用 Hooks

  • 仅从 React 函数中调用 Hooks:从 React 函数组件和自定义 Hooks 中调用 Hooks

React 允许我们编写自定义 Hooks。让我们编写一个自定义 Hook 来处理用户身份验证。在一个新文件中,我们将编写使检索和操作 localStorage 中的 auth 对象更容易的函数。

编写自定义 Hook 的代码

按照以下步骤创建自定义 Hook:

  1. src 目录中,创建一个名为 hooks 的新目录。此目录将包含我们在本书中编写的所有 Hooks。

  2. 在新创建的目录中,添加一个名为 user.actions.js 的文件。

  3. 让我们添加所有必要的内容,从导入开始:

src/hooks/user.actions.js

import axios from "axios";
import { useNavigate } from "react-router-dom";
  1. 接下来,让我们添加一个名为 useUserActions 的函数。自定义 Hook 是一个以 use 开头的 JavaScript 函数:

src/hooks/user.actions.js

function useUserActions() {
 const navigate = useNavigate();
 const baseURL = "http://localhost:8000/api";
 return {
   login,
   register,
   logout,
 };
}

我们现在可以添加 loginlogout 函数。这些函数将返回 Promise,如果成功,将在 localStorage 中注册用户数据并将用户重定向到主页,或者允许我们捕获和处理错误。

  1. 我们现在将编写 register 函数作为练习的一部分,但它与 login 函数没有太大区别:

src/hooks/user.actions.js

...
 // Login the user
 function login(data) {
   return axios.post(`${baseURL}/auth/login/`,
                      data).then((res) => {
     // Registering the account and tokens in the
     // store
     setUserData(data);
     navigate("/");
   });
 }
...
  1. 接下来,编写 logout 函数。此函数将从 localStorage 中删除 auth 项并将用户重定向到登录页面:

src/hooks/user.actions.js

...
 // Logout the user
 function logout() {
   localStorage.removeItem("auth");
   navigate("/login");
 }
...

注意,我们正在使用一个名为 setUserData 的方法,我们尚未声明它。

  1. useUserActions 函数之后,让我们添加其他可以在整个项目中使用的实用函数。这些函数将帮助我们检索访问令牌、刷新令牌、用户信息或设置用户数据:

src/hooks/user.actions.js

// Get the user
function getUser() {
 const auth =
   JSON.parse(localStorage.getItem("auth"));
 return auth.user;
}
// Get the access token
function getAccessToken() {
 const auth =
   JSON.parse(localStorage.getItem("auth"));
 return auth.access;
}
// Get the refresh token
function getRefreshToken() {
 const auth =
   JSON.parse(localStorage.getItem("auth"));
 return auth.refresh;
}
// Set the access, token and user property
function setUserData(data) {
 localStorage.setItem(
   "auth",
   JSON.stringify({
     access: res.data.access,
     refresh: res.data.refresh,
     user: res.data.user,
   })
 );
}

重要提示

你可能会觉得在调用函数之后声明函数很困惑。使用function关键字在 JavaScript 中编写函数允许提升,这意味着函数声明在代码执行之前被移动到其作用域的顶部。你可以在developer.mozilla.org/en-US/docs/Glossary/Hoisting了解更多信息。

通过获取用户、访问和刷新令牌以及设置用户数据在localStorage中的函数,我们现在可以在LoginFormRegisterForm组件中调用该函数。

使用代码中的函数

user.actions.js文件中,我们有一个有用的钩子useUserActions。我们将使用这个钩子来调用login方法,从而替换LoginForm.js文件中的旧登录逻辑。让我们从在LoginForm组件中使用新编写的自定义钩子开始。按照以下步骤操作:

  1. 首先,导入钩子并声明一个新变量:

    ...
    
    import { useUserActions } from "../../hooks/user.actions";
    
    function LoginForm() {
    
     const [validated, setValidated] = useState(false);
    
     const [form, setForm] = useState({});
    
     const [error, setError] = useState(null);
    
     const userActions = useUserActions();
    
    ...
    
  2. 现在,我们可以对 API 中关于登录请求的handleSubmit函数做一些修改:

src/hooks/user.actions.js

const data = {
     username: form.username,
     password: form.password,
   };
   userActions.login(data)
     .catch((err) => {
       if (err.message) {
         setError(err.request.response);
       }
     });
 };

在前面的代码块中,我们通过移除旧的登录逻辑和在localStorage中设置用户数据来进行了一些快速重构。同样的逻辑也可以应用到RegistrationFormregister方法已经在useUserActions钩子中可用)。你可以作为一个小练习修改RegistrationForm组件。请随意检查github.com/PacktPublishing/Full-stack-Django-and-React/blob/chap7/social-media-react/src/components/authentication/RegistrationForm.jsx以确保你的解决方案是有效的。

  1. 太好了!现在让我们使用axios助手和ProtectedRoute组件中的其他实用函数:

src/routes/ProtectedRoute.jsx

...
function ProtectedRoute({ children }) {
 const user = getUser();
 return user ? <>{children}</> : <Navigate
   to="/login/" />;
...
  1. 接下来,让我们对axios助手做一些调整:

    ...
    
    import { getAccessToken, getRefreshToken } from "../hooks/user.actions";
    
    ...
    
     config.headers.Authorization = `Bearer ${getAccessToken()}`;
    
    ...
    
       .post("/refresh/token/", null, {
    
         baseURL: "http://localhost:8000",
    
         headers: {
    
           Authorization: `Bearer ${getRefreshToken()}`,
    
    ...
    
         const { access, refresh, user } = resp.data;
    
         failedRequest.response.config.headers[
    
           "Authorization"] =
    
           "Bearer " + access;
    
         localStorage.setItem("auth", JSON.stringify({
    
           access, refresh, user }));
    
       })
    
       .catch(() => {
    
         localStorage.removeItem("auth");
    
       });
    
    ...
    

在前面的代码块中,我们使用了getAccessTokengetRefreshToken函数从localStorage中检索请求的访问令牌和刷新令牌。我们只是替换了旧的检索访问和刷新令牌的逻辑。

完成了。我们有了纯 React 逻辑的认证流程,这将帮助我们管理以下章节中帖子评论的 CRUD 操作。

摘要

在本章中,我们深入探讨了更多概念,例如在 React 应用程序中的身份验证。我们使用访问令牌在 Django API 上实现了请求的清晰逻辑,并在访问令牌过期时实现了刷新逻辑。我们还有机会使用更多的 Bootstrap 组件来不仅美化登录和注册表单,还创建登录和注册页面。最后,我们实现了一个自定义的 React Hook 来处理前端的所有身份验证相关事宜,包括注册和登录的方法,以及一些从 localStorage 获取令牌的实用工具,并将令牌和用户数据设置在 localStorage 中。自定义 Hook 的创建帮助我们根据 DRY 原则对代码库进行了一些重构。

在下一章中,我们将允许用户从 React 应用程序中创建帖子。我们将学习如何使用自定义编写的 axiosService 向后端发送请求,显示模态框,处理更复杂的 React 状态,并使用 useContext React Hook 来处理弹出显示。

问题

  1. localStorage 是什么?

  2. React-Router 是什么?

  3. 你如何在 React 中配置受保护的路由?

  4. 什么是 React Hook?

  5. 请列举三个 React Hooks 的例子。

  6. React Hooks 有哪两条规则?

第八章:社交媒体帖子

社交媒体已经在前端添加了认证。我们现在可以通过注册或登录来认证用户,获取用户数据,并显示它们。现在我们可以存储 JWT 令牌,我们可以向 API 发送请求以获取任何受保护的资源,我们将从post资源开始。

在本章中,我们将关注对帖子的CRUD操作。我们将实现列出、创建、更新和删除帖子功能。您将学习如何在 React 中创建和管理模态框,如何处理从验证到提交的表单,以及如何设计和集成组件到 React 页面中。

本章将涵盖以下主题:

  • 在 Feed 中列出帖子

  • 使用表单创建帖子

  • 编辑和删除帖子

  • 点赞帖子

技术要求

确保您的机器上已安装并配置了 VS Code 和更新的浏览器。您可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap8找到本章的代码。

创建 UI

REST API 已准备好接受请求并列出 API。对于下一步,请确保 Django 服务器在机器上的localhost:8000端口运行。第一步是实现一个带有现成设计和 UI 的帖子流。在编写读取、创建、更新和删除组件的代码之前,我们需要分析 UI,并确保我们有正确的配置和组件来简化使用 React 的开发。我们将主要构建导航栏和布局。

这是主页的 Feed UI:

图 8.1 – Feed UI 线框图

图 8.1 – Feed UI 线框图

在以下图中,我们有一个表示 UI 和页面结构的另一个插图。我们使用 flex 列,我们将使用 Bootstrap flex 组件来设计页面:

图 8.2 – 线框图

图 8.2 – 线框图

导航栏将在 React 应用的其它页面上可用,通过将导航栏做成组件,它可以被复用。我们可以通过拥有一个Layout组件来简化导航栏的集成,这个组件在构建页面时将被使用。让我们先添加导航栏组件。

添加 NavBar 组件

NavBar组件,或导航栏组件,应该有助于快速导航 UI。以下是NavBar组件的截图:

图 8.3 – Navbar

图 8.3 – Navbar

NavBar将包含三个链接:

  • 一个重定向到 Feed 页面的链接(1

  • 一个重定向到个人资料页面的链接(2

  • 一个注销链接(3

这里有一个简单的线框图,以更好地说明链接将放置的位置。

图 8.4 – Navbar 线框图

图 8.4 – Navbar 线框图

让我们添加这个组件。按照以下步骤操作:

  1. src/components/目录内,添加一个名为Navbar.jsx的新文件。此文件将包含NavBar组件的代码。Bootstrap 已经提供了一个我们可以使用的NavBar组件。让我们从组件定义和必要的导入开始:

src/components/Navbar.jsx

import React from "react";
import { randomAvatar } from "../utils";
import { Navbar, Container, Image, NavDropdown, Nav } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
...
  1. 使用已经编写的函数,我们可以添加NavBar组件并对其进行样式化。react-bootstrap提供了我们可以使用的组件,可以使我们组件的编码更快。组件所需的 props 使得这些组件的定制更加容易:

src/components/Navbar.jsx

...
function Navigationbar() {
 return (
   <Navbar bg="primary" variant="dark">
     <Container>
       <Navbar.Brand className="fw-bold" href="#home">
         Postagram
       </Navbar.Brand>
       <Navbar.Collapse
         className="justify-content-end">
         <Nav>
           <NavDropdown
             title={
               <Image
                 src={randomAvatar()}
                 roundedCircle
                 width={36}
                 height={36}
               />
             }
           >
             <NavDropdown.Item href="#">Profile
             </NavDropdown.Item>
             <NavDropdown.Item onClick={handleLogout}>
               Logout</NavDropdown.Item>
           </NavDropdown>
         </Nav>
       </Navbar.Collapse>
     </Container>
   </Navbar>
 );
}
export default Navigationbar;
  1. 让我们添加处理登出的函数:

src/components/Navbar.jsx

...
function Navigationbar() {
 const navigate = useNavigate();
 const handleLogout = () => {
   localStorage.removeItem("auth");
   navigate("/login/");
 };
...

我将使用一个生成随机头像的网站。在下一章中,我们将进行一个小练习来添加上传个人头像的功能,但此刻图像生成器将完成这项工作。

  1. src目录下,添加一个名为utils.js的新文件。此文件将包含我们在 React 应用程序中会重用的函数:

src/utils.js

export const randomAvatar = () =>
 `https://i.pravatar.cc/300?img=${Math.floor(Math.random() * 60) + 1}`;

pravatar服务支持 URL 中的参数,并且有超过 60 个图像。我们正在使用 Math 库生成一个代表图像 ID 的随机数。现在我们可以编写Layout组件。

添加 Layout 组件

一个好的 React 项目应该具有视觉一致性,但也应该减少代码的重复。例如,这个 React 项目的导航栏将出现在主页上,也会出现在个人资料页上。如果我们直接在 HTML 和 CSS 中开发,我们会重复相同的代码来创建导航栏,但我们可以通过创建Layout组件来避免重复。

src/components目录下,添加一个名为Layout.jsx的文件。此文件将包含Layout组件的代码:

src/components/Layout.jsx

import React from "react";
import Navigationbar from "./Navbar";
function Layout(props) {
 return (
   <div>
     <Navigationbar />
     <div className="container m-5">{props.children}</div>
   </div>
 );
}
export default Layout;

在这里我们有一个新的语法:children。在 React 中,children用于显示在调用组件时在打开和关闭标签之间包含的内容。以下是一个使用图像组件的简单示例:

const Picture = (props) => {
  return (
    <div>
      <img src=""/>
      {props.children}
    </div>
  )
}

该组件可以被使用,并且我们可以添加内容或其他组件:

render () {
  return (
    <div className='container'>
      <Picture>
          <p>This a children element.</p>
      </Picture>
    </div>
  )
}

每当调用Picture组件时,props.children也会显示,这只是一个对组件打开和关闭标签的引用。在我们的上下文中,props.children将包含 React 应用程序页面的大部分内容。

例如,在主页上,我们有帖子和个人资料列表;这些元素将是Layout组件的子元素。现在就让我们使用Layout组件。

在主页上使用 Layout 组件

Home.jsx内部,我们将重写代码以使用Layout组件。以下是新代码:

src/pages/Home.jsx

import React from "react";
import Layout from "../components/Layout";
function Home() {
 return (
   <Layout>
   </Layout>
 );
}
export default Home;

太好了。让我们首先添加输入以创建一个新的帖子,如图图 8**.2所示。

创建帖子

要创建和添加帖子,请按照以下步骤操作:

  1. src/components 中添加一个名为 posts 的新目录。此目录将包含用于帖子功能的所有组件。我们将有创建帖子、显示帖子和更新帖子的组件。

  2. 在新创建的目录中,添加一个名为 CreatePost.jsx 的文件。此文件将包含创建帖子所需逻辑和 UI 的代码。

  3. 我们这里有一个名为 Modal 的 UI 组件。react-bootstrap 提供了一个为模态准备的元素,我们可以轻松地根据我们的需求进行定制。让我们首先添加所需的导入并定义组件函数:

src/components/post/CreatePost.jsx

import React, { useState } from "react";
import { Button, Modal, Form } from "react-bootstrap";
import axiosService from "../../helpers/axios";
import { getUser } from "../../hooks/user.actions";
function CreatePost()
  return ()
};
export default CreatePost;
  1. 帖子创建的输入将在 Modal 组件内。正如我们之前所做的那样,我们还将添加方法和状态管理以处理表单。但首先,让我们编写模态和可点击的输入:

src/components/post/CreatePost.jsx

…
function CreatePost() {
 const [show, setShow] = useState(false);
 const handleClose = () => setShow(false);
 const handleShow = () => setShow(true);
 return (
   <>
     <Form.Group className="my-3 w-75">
       <Form.Control
         className="py-2 rounded-pill border-primary
                    text-primary"
         type="text"
         placeholder="Write a post"
         onClick={handleShow}
       />
     </Form.Group>
     {/*Add modal code here*/}
   </>
 );
}
export default CreatePost;
  1. 我们首先添加将触发模态显示的输入。点击模态将 show 状态设置为 True,这是用于打开模态的状态。让我们添加模态的代码:

src/components/post/CreatePost.jsx

...
     <Modal show={show} onHide={handleClose}>
       <Modal.Header closeButton className="border-0">
         <Modal.Title>Create Post</Modal.Title>
       </Modal.Header>
       <Modal.Body className="border-0">
         <Form noValidate validated={validated}
           onSubmit={handleSubmit}>
           <Form.Group className="mb-3">
             <Form.Control
               name="body"
               value={form.body}
               onChange={(e) => setForm({ ...form,
                 body: e.target.value })}
               as="textarea"
               rows={3}
             />
           </Form.Group>
         </Form>
       </Modal.Body>
       <Modal.Footer>
         <Button variant="primary"
           onClick={handleSubmit}
           disabled={form.body === undefined}>
           Post
         </Button>
       </Modal.Footer>
     </Modal>
...
  1. 模态的 UI 已创建。我们现在需要添加 handleSubmit 函数和其他表单处理逻辑:

src/components/post/CreatePost.jsx

function CreatePost() {
...
 const [validated, setValidated] = useState(false);
 const [form, setForm] = useState({});
 const user = getUser();
...
 const handleSubmit = (event) => {
   event.preventDefault();
   const createPostForm = event.currentTarget;
   if (createPostForm.checkValidity() === false) {
     event.stopPropagation();
   }
   setValidated(true);
   const data = {
     author: user.id,
     body: form.body,
   };
   axiosService
     .post("/post/", data)
     .then(() => {
       handleClose();
       setForm({});
     })
     .catch((error) => {
       console.log(error);
     });
 };
...

图 8.5 – 创建帖子组件

图 8.5 – 创建帖子组件

我们几乎完成了,但我们需要每个动作的一个基本功能,例如表单处理。我们需要向用户发送反馈,告诉他们他们的请求是否成功。在我们的上下文中,当用户创建帖子时,我们将显示成功吐司或错误吐司:

图 8.6 – 成功的吐司

图 8.6 – 成功的吐司

吐司将被用于帖子删除和更新。它还将用于评论创建、修改和删除,以及我们稍后将要添加的配置文件修改。我们将在下一节中添加 Toast 组件。

添加吐司组件

让我们快速创建一个名为 Toaster 的组件,我们将在 React 应用程序中使用它来显示吐司。

src/components 中创建一个名为 Toaster.jsx 的新文件。此文件将包含 Toaster 组件的代码:

src/components/Toaster.jsx

import React from "react";
import { Toast, ToastContainer } from "react-bootstrap";
function Toaster(props) {
 const { showToast, title, message, onClose, type } =
   props;
 return (
   <ToastContainer position="top-center">
     <Toast onClose={onClose} show={showToast} delay={3000}
       autohide bg={type}>
       <Toast.Header>
         <strong className="me-auto">{title}</strong>
       </Toast.Header>
       <Toast.Body>
         <p className="text-white">{message}</p>
       </Toast.Body>
     </Toast>
   </ToastContainer>
 );
}
export default Toaster;

Toaster 组件接受一些属性:

  • showToast: 用于显示吐司或隐藏的布尔值。理想情况下,根据我们从服务器请求中收到的输出,我们将状态设置为 true,这将显示吐司。

  • title: 这表示吐司的标题。

  • message: 这传达了我们将要在吐司中显示的消息。

  • onClose: 处理吐司关闭功能的函数。此函数是必需的;否则,吐司将永远不会消失。

  • type: 这表示要显示的吐司类型。在我们的上下文中,我们将使用 successdanger

让我们在 CreatePost.jsx 中导入此组件并使用它。

在创建帖子时添加吐司

CreatePost.jsx文件中,我们将添加新的状态,并将这些状态作为 props 传递给Toaster组件:

src/components/post/CreatePost.jsx

…
import Toaster from "../Toaster";
function CreatePost() {
...
 const [showToast, setShowToast] = useState(false);
 const [toastMessage, setToastMessage] = useState("");
 const [toastType, setToastType] = useState("");
...
 const handleSubmit = (event) => {
   ...
   axiosService
     .post("/post/", data)
     .then(() => {
       handleClose();
       setToastMessage("Post created 🚀");
       setToastType("success");
       setForm({});
       setShowToast(true);
     })
     .catch(() => {
       setToastMessage("An error occurred.");
       setToastType("danger");
     });
 };

我们可以导入Toaster组件,并将新添加的状态作为 props 传递:

src/components/post/CreatePost.jsx

...
   </Modal>
     <Toaster
       title="Post!"
       message={toastMessage}
       showToast={showToast}
       type={toastType}
       onClose={() => setShowToast(false)}
     />
   </>
...

我们已经完成了CreatePost组件的编写。对于下一步,我们需要将其集成到主页上。

将 CreatePost 组件添加到主页

CreatePost组件现在已准备好,我们可以使用它。首先,将其导入到Home.jsx文件中,并修改 UI。

主页将分为两部分:

  • 第一部分将包含帖子列表(1图 8**.7

  • 第二部分将包括五个配置文件列表(2图 8**.7

图 8.7 – 主页结构

图 8.7 – 主页结构

我们可以通过使用react-bootstrap提供的行和列组件快速实现结果。目前我们不会关注第二部分(列出配置文件)。让我们确保我们为帖子功能有所有CRUD操作:

  1. Home.jsx文件内部,添加以下内容。我们将首先导入并添加行:

src/pages/Home.jsx

import React from "react";
import Layout from "../components/Layout";
import { Row, Col, Image } from "react-bootstrap";
import { randomAvatar } from "../utils";
import useSWR from "swr";
import { fetcher } from "../helpers/axios";
import { getUser } from "../hooks/user.actions";
import CreatePost from "../components/posts/CreatePost";
function Home() {
 const user = getUser();
 if (!user) {
   return <div>Loading!</div>;
 }
 return (
   <Layout>
     <Row className="justify-content-evenly">
       <Col sm={7}>
         <Row className="border rounded
           align-items-center">
           <Col className="flex-shrink-1">
             <Image
               src={randomAvatar()}
               roundedCircle
               width={52}
               height={52}
               className="my-2"
             />
           </Col>
           <Col sm={10} className="flex-grow-1">
             <CreatePost />
           </Col>
         </Row>
       </Col>
     </Row>
   </Layout>
 );
}
export default Home;
  1. 太好了!确保保存更改,启动服务器,然后转到主页。你将看到类似以下内容:

图 8.8 – 创建帖子 UI

图 8.8 – 创建帖子 UI

  1. 点击输入框,将弹出一个模态框。在输入框中输入任何内容并提交。模态框将关闭,你将在页面顶部中央看到一个提示:

图 8.9 – 成功创建帖子后的提示

图 8.9 – 成功创建帖子后的提示

太好了!我们现在可以使用我们的 React 应用程序创建帖子。为了使其成为可能,我们创建了一个Modal组件和一个带有 React Bootstrap 的表单来处理数据验证和提交。并且因为反馈是用户体验的重要方面,我们添加了一个使用 React Bootstrap 的 toaster,并将其与useContext钩子集成,以通知用户请求的结果。

下一步是列出所有帖子并添加如删除和修改等操作。

在主页上列出帖子

现在用户可以创建帖子,我们需要在主页上列出帖子,同时也允许用户访问它们。这需要创建一个组件来显示帖子的信息。如图图 8**.1所示,在写帖子输入框下方,我们有一个帖子列表。主页结构已经添加,因此我们需要添加一个组件来处理显示帖子信息背后的逻辑。

这是列出主页上帖子的流程:

  • 我们使用swr库获取帖子列表

  • 我们遍历帖子列表,然后将帖子作为 props 传递给一个名为Post的组件,该组件将显示帖子的数据

在开始获取数据之前,让我们创建Post组件。

编写帖子组件

要创建一个Post组件,请按照以下步骤操作:

  1. src/components/post/目录内,创建一个名为Post.jsx的新文件。这个文件将包含显示帖子数据和如点赞或取消点赞、删除和修改等逻辑。以下是Post组件的线框图:

图 8.10 – 帖子组件

图 8.10 – 帖子组件

  1. 为了使事情更快,我们将使用react-bootstrap提供的Card组件。Card组件包含一个包含标题、正文和页脚的结构:

src/components/post/Post.jsx

import React, { useState } from "react";
import { format } from "timeago.js";
import {
 LikeFilled,
 CommentOutlined,
 LikeOutlined,
} from "@ant-design/icons";
import { Image, Card, Dropdown } from "react-bootstrap";
import { randomAvatar } from "../../utils";
function Post(props) {
 const { post, refresh } = props;
 const handleLikeClick = (action) => {
   axiosService
     .post(`/post/${post.id}/${action}/`)
     .then(() => {
       refresh();
     })
     .catch((err) => console.error(err));
 };
 return (
   <>
     <Card className="rounded-3 my-4">
      {/* Add card body here*/}
     </Card>
   </>
 );
}
export default Post;

Post组件接受两个属性:

  • 包含帖子数据的post对象。

  • refresh函数。这个函数将来自 SWR 的posts对象,SWR 返回一个包含mutate方法的对象,可以用来触发数据的获取。

  1. 我们还从添加handleLikeClick函数中受益。可以将两个操作传递给该函数:要么是like,要么是remove_like。如果请求成功,我们可以刷新帖子。太好了!让我们先添加Card的正文。它将包含帖子的作者头像、姓名和帖子创建以来的时间:

src/components/post/Post.jsx

…
<Card.Body>
         <Card.Title className="d-flex flex-row
           justify-content-between">
           <div className="d-flex flex-row">
             <Image
               src={randomAvatar()}
               roundedCircle
               width={48}
               height={48}
               className="me-2 border border-primary
                          border-2"
             />
             <div className="d-flex flex-column
               justify-content-start
               align-self-center mt-2">
               <p className="fs-6 m-0">
                 {post.author.name}</p>
               <p className="fs-6 fw-lighter">
                 <small>{format(post.created)}</small>
               </p>
             </div>
           </div>
         </Card.Title>
       </Card.Body>
...
  1. 继续添加帖子的正文和点赞数:

src/components/post/Post.jsx

…
         </Card.Title>
         <Card.Text>{post.body}</Card.Text>
         <div className="d-flex flex-row">
           <LikeFilled
             style={{
               color: "#fff",
               backgroundColor: "#0D6EFD",
               borderRadius: "50%",
               width: "18px",
               height: "18px",
               fontSize: "75%",
               padding: "2px",
               margin: "3px",
             }}
           />
           <p className="ms-1 fs-6">
             <small>{post.likes_count} like</small>
           </p>
         </div>
       </Card.Body>
...
  1. 我们现在可以移动到包含点赞和评论 UI 的Card页脚。让我们先添加点赞图标,然后是文本:

src/components/post/Post.jsx

...
       </Card.Body>
       <Card.Footer className="d-flex bg-white w-50
         justify-content-between border-0">
         <div className="d-flex flex-row">
           <LikeOutlined
             style={{
               width: "24px",
               height: "24px",
               padding: "2px",
               fontSize: "20px",
               color: post.liked ? "#0D6EFD" :
                 "#C4C4C4",
             }}
             onClick={() => {
               if (post.liked) {
                 handleLikeClick("remove_like");
               } else {
                 handleLikeClick("like");
               }
             }}
           />
           <p className="ms-1">
             <small>Like</small>
           </p>
         </div>
 {/* Add comment icon here*/}
       </Card.Footer>
     </Card>
...
  1. 现在继续添加评论图标,然后是文本:

src/components/post/Post.jsx

...
         <div className="d-flex flex-row">
           <CommentOutlined
             style={{
               width: "24px",
               height: "24px",
               padding: "2px",
               fontSize: "20px",
               color: "#C4C4C4",
             }}
           />
           <p className="ms-1 mb-0">
             <small>Comment</small>
           </p>
         </div>
       </Card.Footer>
...

Post组件已经完全编写完成;我们现在可以在主页上使用它了。

Post组件添加到主页

现在我们将把我们的Post组件添加到主页上。

Home.jsx文件中,导入Post组件:

src/pages/Home.jsx

...
import { Post } from "../components/posts";
...

我们现在可以通过首先从服务器获取帖子来在代码中使用这些组件:

src/pages/Home.jsx

...
function Home() {
 const posts = useSWR("/post/", fetcher, {
   refreshInterval: 10000,
 });
...

useSWR钩子可以接受一些参数,例如refreshInterval。在这里,返回的数据每 10 秒刷新一次。我们现在可以在 UI 中使用这些对象:

src/pages/Home.jsx

...
           <Col sm={10} className="flex-grow-1">
             <CreatePost />
           </Col>
         </Row>
         <Row className="my-4">
           {posts.data?.results.map((post, index) => (
             <Post key={index} post={post}
               refresh={posts.mutate} />
           ))}
         </Row>
       </Col>
...

太好了!在主页上添加了Post组件后,你应该会有类似这样的结果:

图 8.11 – 帖子列表

图 8.11 – 帖子列表

你可以点击右上角的Post组件的更多下拉菜单:

图 8.12 – 添加更多下拉菜单

图 8.12 – 添加更多下拉菜单

react-bootstrap提供了一个我们可以使用的Dropdown组件,我们可以用它达到相同的效果。在Post.jsx文件中,从react-bootstrap导入Dropdown组件。由于我们将添加帖子删除的逻辑,让我们也导入Toaster组件:

src/components/post/Post.jsx

import { Button, Modal, Form, Dropdown } from "react-bootstrap";
import Toaster from "../Toaster";
...

然后我们必须编写我们将传递给Dropdown组件作为标题的组件:

src/components/post/Post.jsx

…
const MoreToggleIcon = React.forwardRef(({ onClick }, ref) => (
 <Link
   to="#"
   ref={ref}
   onClick={(e) => {
     e.preventDefault();
     onClick(e);
   }}
 >
   <MoreOutlined />
 </Link>
));
function Post(props) {
...

我们现在可以将Dropdown组件添加到 UI 中。我们需要使其有条件,以便只有帖子的作者可以访问这些选项。我们只需从localStorage检索用户,并将user.idauthor.id进行比较:

src/components/post/Post.jsx

...
function Post(props) {
...
 const [showToast, setShowToast] = useState(false);
 const user = getUser();
...
 const handleDelete = () => {
   axiosService
     .delete(`/post/${post.id}/`)
     .then(() => {
       setShowToast(true);
       refresh();
     })
     .catch((err) => console.error(err));
 };
 return (
 ...

让我们添加组件 UI 和Toaster组件:

src/components/post/Post.jsx

...
     <Card className="rounded-3 my-4">
       <Card.Body>
         <Card.Title className="d-flex flex-row
           justify-content-between">
           ...
           {user.name === post.author.name && (
             <div>
               <Dropdown>
                 <Dropdown.Toggle as={MoreToggleIcon}>
                 </Dropdown.Toggle>
                 <Dropdown.Menu>
                   <Dropdown.Item>Update</>
                   <Dropdown.Item
                     onClick={handleDelete}
                     className="text-danger"
                   >
                     Delete
                   </Dropdown.Item>
                 </Dropdown.Menu>
               </Dropdown>
             </div>
           )}
         </Card.Title>
         ...
     </Card>
     <Toaster
       title="Post!"
       message="Post deleted"
       type="danger"
       showToast={showToast}
       onClose={() => setShowToast(false)}
     />
   </>
 );
}
export default Post;

Dropdown组件也被添加到提示器中。每次删除帖子时,页面上方中央都会弹出一个红色的提示器:

图 8.13 – 删除帖子

图 8.13 – 删除帖子

用户现在可以删除自己的帖子,并且这个功能可以直接从Post组件中访问。我们已经探讨了如何再次使用UseContex钩子,以及如何使用react-bootstrap创建下拉菜单。

帖子功能的CRUD操作几乎完成,只剩下更新功能。这很简单,你将作为一个小练习来实现它,但我将添加必要的代码和说明。

更新帖子

如前所述,这个功能的实现是一个简单的练习。以下是用户在修改帖子时通常会遵循的流程:

  1. 点击更多下拉菜单。

  2. 选择修改选项。

  3. 将显示一个包含帖子内容的模态框,用户可以对其进行修改。

  4. 完成后,用户保存,模态框关闭。

  5. 将弹出一个包含帖子 更新 🚀内容的提示框。

这个功能与CreatePost.jsx类似;区别在于UpdatePost组件将接收一个post对象作为属性。以下是代码的框架:

src/components/post/UpdatePost.jsx

import React, { useState } from "react";
import { Button, Modal, Form, Dropdown } from "react-bootstrap";
import axiosService from "../../helpers/axios";
import Toaster from "../Toaster";
function UpdatePost(props) {
 const { post, refresh } = props;
 const [show, setShow] = useState(false);
 const handleClose = () => setShow(false);
 const handleShow = () => setShow(true);
 // Add form handling logic here
 return (
   <>
     <Dropdown.Item onClick={handleShow}>Modify
     </Dropdown.Item>
     <Modal show={show} onHide={handleClose}>
     {/*Add UI code here*/}
     </Modal>
   </>
 );
}
export default UpdatePost;

该组件在Post.jsx文件中被调用,并像这样使用:

src/components/post/Post.jsx

...
import UpdatePost from "./UpdatePost";
...
           </div>
           {user.name === post.author.name && (
             <div>
               <Dropdown>
                 <Dropdown.Toggle as={MoreToggleIcon}>
                 </Dropdown.Toggle>
                 <Dropdown.Menu>
                   <UpdatePost post={post}
                     refresh={refresh} />
                   <Dropdown.Item
                     onClick={handleDelete}
                     className="text-danger"
                   >
                     Delete
                   </Dropdown.Item>
                 </Dropdown.Menu>
               </Dropdown>
             </div>
           )}
...

祝你练习顺利。你可以在github.com/PacktPublishing/Full-stack-Django-and-React/blob/main/social-media-react/src/components/posts/UpdatePost.jsx找到解决方案。

小型重构

首先,创建新帖子时不会进行刷新。正如我们在UpdatePost.jsx组件中所做的那样,我们也可以向CreatePost组件传递一些属性:

src/pages/Home.jsx

...
           <Col sm={10} className="flex-grow-1">
             <CreatePost refresh={posts.mutate} />
           </Col>
...

此外,当帖子成功创建时,我们可以调用refresh方法:

src/components/posts/CreatePost.jsx

function CreatePost(props) {
  const { refresh } = props;
  ...
    axiosService
      .post("/post/", data)
      .then(() => {
     ...
        setForm({});
        setShowToast(true);
        refresh();
      })
...

现在,每次用户添加帖子时,他都会在主页上看到新创建的帖子,而无需重新加载页面。

其次,Toaster组件已创建,但我们需要考虑如何在项目中调用该组件。让我们不要忘记,这个组件是为了向用户返回关于成功或失败的请求的反馈而创建的,因此该组件应该在整个项目中可重用,这正是我们实际所做的事情,对吧?

嗯,不是的,这并不理想,因为它将违反组件层次结构中更高的Toaster组件,然后能够从任何子组件中调用或显示吐司?

图 8.14 – 父组件和子组件

图 8.14 – 父组件和子组件

在前面的图中,我们将能够从子组件(CreatePost)直接在父组件中触发项目中吐司的显示。React 提供了一种有趣的方式来管理父组件和子组件之间的状态,这被称为上下文。在Layout.jsx文件中,使用createContext方法创建一个新的上下文:

src/components/Layout.jsx

import React, { createContext, useMemo, useState } from "react";
export const Context = createContext("unknown");
function Layout(props) {

然后在Layout组件的作用域内,让我们定义包含吐司将用于显示信息的数据的状态。我们还将将组件 JSX 内容包裹在Context组件内部,并添加一个方法来从Layout组件的任何子组件中修改状态:

src/components/Layout.jsx

function Layout(props) {
  ...
  const [toaster, setToaster] = useState({
   title: "",
   show: false,
   message: "",
   type: "",
 });
 const value = useMemo(() => ({ toaster, setToaster }),
                                [toaster]);
 ...
 return (
   <Context.Provider value={value}>
     <div>
       <NavigationBar />
       {hasNavigationBack && (
         <ArrowLeftOutlined
           style={{
             color: "#0D6EFD",
             fontSize: "24px",
             marginLeft: "5%",
             marginTop: "1%",
           }}
           onClick={() => navigate(-1)}
         />
       )}
       <div className="container my-2">
         {props.children}</div>
     </div>
     <Toaster
       title={toaster.title}
       message={toaster.message}
       type={toaster.type}
       showToast={toaster.show}
       onClose={() => setToaster({ ...toaster, show: false
         })}
     />
   </Context.Provider>
 );
}
export default Layout;

在前面的代码中,我们介绍了一个新的函数 Hook,称为useMemo,它有助于记住上下文值(缓存上下文的值)并避免每次Layout组件重新渲染时创建新对象。

然后,我们将能够访问toaster状态并从任何子组件中调用setToaster函数:

 const { toaster, setToaster } = useContext(Context);

摘要

在本章中,我们通过创建用于帖子功能的 CRUD 操作所需的组件,更深入地了解了 React 编程。我们涵盖了诸如属性传递、父-子组件创建、UI 组件定制和模态框创建等概念。这导致了Postagram项目主页的部分完成。我们还学习了更多关于useStateuseContextHooks 以及它们如何影响 React 中的状态的知识。我们还学习了如何创建Dropdown组件,如何创建自定义吐司,以及在 React 项目中布局的重要性。

在下一章中,我们将专注于评论功能的 CRUD 操作。这将导致我们添加一个个人资料页面和一个帖子页面来显示评论。我们还将进行简单快速的评估,以添加到评论中的点赞功能。

问题

  1. 什么是模态框?

  2. 什么是属性?

  3. React 中的子元素是什么?

  4. 什么是线框图?

  5. JSX 中使用的map方法是什么?

  6. SWR 对象上mutate方法的用法是什么?

第九章:帖子评论

每个社交媒体平台的一个令人兴奋的部分是评论功能。在前一章中,我们已经添加了帖子创建、列出、更新和删除功能。本章将涵盖评论的创建、列出、更新和删除。我们将创建一个页面来显示关于帖子的信息,添加组件来列出评论,添加一个模态来显示创建评论的表单,并添加一个下拉菜单以允许用户删除或修改评论。在本章结束时,你将学习如何使用 React 和 React Router 通过 URL 参数导航到单个页面。

在本章中,我们将涵盖以下主题:

  • 在帖子页面上列出评论

  • 使用表单创建评论

  • 编辑和删除评论

  • 更新评论

技术要求

确保在您的机器上安装并配置了 VS Code 和更新的浏览器。您可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap9找到本章的代码。

创建 UI

在接下来的段落中,我们将修改Post组件以确保在显示单个帖子时的一致性,并添加一个Post组件。在列出评论之前,我们需要确保用户可以创建评论。这需要构建一个名为SinglePost的页面,该页面将显示关于帖子和评论的详细信息。

让我们看看以下图中的页面 UI:

图 9.1 – SinglePost 页面的结果

图 9.1 – SinglePost 页面的结果

前面的图中的 UI 给了我们一个好的结果。当页面构建完成,用户点击评论时,将出现一个模态,用户将能够创建评论。让我们先关注这个案例,稍后我们将探索其他 CRUD 操作。

注意,我们还在页面的左上角添加了一个返回按钮——这是要添加到Layout组件中的内容。我们首先将对Post.jsx组件进行一些调整。这是因为我们将重用Post组件,但我们将屏蔽如评论计数和评论图标等选项。修改组件后,我们将创建一个显示一篇文章及其评论的页面。

调整 Post 组件

Post组件将被简单地重用来显示更多关于帖子的信息。根据图 9**.1中的 UI,我们只需屏蔽帖子上的评论数量和评论图标。

Post.jsx内部,我们将添加另一个名为isSinglePost的 prop。当这个 prop 为true时,意味着我们在SinglePost页面上显示组件:

src/components/posts/Post.jsx

...
function Post(props) {
 const { post, refresh, isSinglePost } = props;
...
 return (
   <>
    ...
            {!isSinglePost && (
             <p className="ms-1 fs-6">
               <small>
                 <Link>
                   {post.comments_count} comments
                 </Link>
               </small>
             </p>
           )}
   ...
                {!isSinglePost && (
           <div className="d-flex flex-row">
             <CommentOutlined
               style={{
                 width: "24px",
                 height: "24px",
                 padding: "2px",
                 fontSize: "20px",
                 color: "#C4C4C4",
               }}
             />
             <p className="ms-1 mb-0">
               <small>Comment</small>
             </p>
           </div>
         )}
...

在对Post组件进行修改后,我们现在可以向Layout组件添加返回按钮。

向 Layout 组件添加返回按钮

返回按钮的作用是在动作被发起时将用户导航到上一页。关于如何实现这一点的一个有趣的想法是将实际路径添加到可以发生返回动作的组件中。然而,这将需要很多代码,并引入一些复杂性。

幸运的是,react-router库提供了一种简单的方法,只需一行代码就可以导航到上一页:

navigate(-1)

是的!让我们把这个函数添加到Layout.jsx组件中:

src/components/Layout.jsx

import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
function Layout(props) {
 const { hasNavigationBack } = props;
 const navigate = useNavigate();
...
  return (
     <div>
       <Navigationbar />
       {hasNavigationBack && (
         <ArrowLeftOutlined
           style={{
             color: "#0D6EFD",
             fontSize: "24px",
             marginLeft: "5%",
             marginTop: "1%",
           }}
           onClick={() => navigate(-1)}
         />
       )}
       <div className="container my-2">
           {props.children}
       </div>
...

在前面的代码中,我们添加了一个名为hasNavigationBack的属性。这个属性将告诉 React 是否应该渲染导航到上一页的图标。渲染过程是在 JSX 代码中完成的,使用条件。如果hasNavigationBacktrue,我们显示返回图标,用户可以导航。

添加了返回选项后,我们现在可以开始编写SinglePost.jsx页面。

创建 SinglePost 组件

src/pages目录中,创建一个名为SinglePost.jsx的新文件。这个文件将包含显示帖子信息和,最重要的是,评论的代码。以下图显示了页面的简单线框,这样我们就可以对组件的布局有一个概念:

图 9.2 – SinglePost 页面的线框

图 9.2 – SinglePost 页面的线框

让我们移动文件并开始编码。在下面的代码片段中,我们将创建SinglePost页面,添加导入,并定义页面上将使用的函数和状态:

src/pages/SinglePost.jsx

import React from "react";
import Layout from "../components/Layout";
import { Row, Col } from "react-bootstrap";
import { useParams } from "react-router-dom";
import useSWR from "swr";
import { fetcher } from "../helpers/axios";
import { Post } from "../components/posts";
import CreateComment from "../components/comments/CreateComment";
import Comment from "../components/comments/Comment";
function SinglePost() {
 let { postId } = useParams();
 const post = useSWR(`/post/${postId}/`, fetcher);
 const comments = useSWR(`/post/${postId}/comment/`, fetcher);
 return (
   <Layout hasNavigationBack>
     {post.data ? (
       <Row className="justify-content-center">
         <Col sm={8}>
           <Post post={post.data} refresh={post.mutate}              isSinglePost />
           // Adding CreateComment form and list all comments               here
         </Col>
       </Row>
     ) : (
       <div>Loading...</div>
     )}
   </Layout>
 );
}
export default SinglePost;

我们再次使用了来自react-bootstrapRowCol功能。这种结构将帮助我们有一个占布局 8/12 的一列,并且对象居中。接下来,我们需要一个用于创建评论的表单。

我们还在使用一个新的钩子,useParams。正如官方文档所述,useParams钩子返回一个<Route path>的对象。子路由继承其父路由的所有参数。

稍微有点难以理解,但让我们注册这个页面并在浏览器中加载它。在App.jsx文件中,添加一个新的路由:

src/App.jsx

...
function App() {
 return (
   <Routes>
     <Route
       path="/"
       element={
         <ProtectedRoute>
           <Home />
         </ProtectedRoute>
       }
     />
     <Route
       path="/post/:postId/"
       element={
         <ProtectedRoute>
           <SinglePost />
         </ProtectedRoute>
       }
     />
...

新增路由的路径有一个有趣的模式,随着postId的添加。我们可以告诉react-router期待一个将要传递的参数,然后这个参数将在useParams钩子中可用。

让我们在Post组件中添加重定向到SinglePost页面的功能:

src/components/posts/CreatePost.jsx

return (
   <>
    ...
            {!isSinglePost && (
             <p className="ms-1 fs-6">
               <small>
                 <Link to={`/post/${post.id}/`}>
                   {post.comments_count} comments
                 </Link>
               </small>
             </p>
           )}
    ...

SinglePost.jsx文件中,添加useParams()console日志:

src/pages/SinglePost.jsx

...
function SinglePost() {
 console.log(useParams())
 let { postId } = useParams();
...

进入浏览器并点击一个帖子以访问SinglePost页面。你将得到类似的结果:

图 9.3 – 帖子页面

图 9.3 – 帖子页面

检查浏览器控制台以查看useParams()的内容:

图 9.4 – useParams()的内容

图 9.4 – useParams()的内容

我们有一个包含postId值的对象。使用已解释的useParams(),让我们继续添加CreateComment表单。

创建评论

src/components目录内,创建一个名为comments的新目录。这个目录将包含comments功能组件的代码。在新建的目录内,创建一个名为CreateComment.jsx的新文件。这个组件代表用户将用来添加评论到帖子的表单。

文件创建完成后,添加所需的导入:

src/components/comments/CreateComment.jsx

import React, { useState, useContext } from "react";
import { Button, Form, Image } from "react-bootstrap";
import axiosService from "../../helpers/axios";
import { getUser } from "../../hooks/user.actions";
import { randomAvatar } from "../../utils";
import { Context } from "../Layout";
function CreateComment(props) {
 const { postId, refresh } = props;
 return (
   <Form>
   </Form>
 );
}
export default CreateComment;

CreateComment页面上,当进行 CRUD 操作时,我们将显示 toast 通知。这意味着我们将再次使用Context方法。

让我们先定义 props 并创建handleSubmit。这个过程将与我们在CreatePost组件中做的非常相似:

src/components/comments/CreateComment

...
function CreateComment(props) {
 const { postId, refresh } = props;
 const [avatar, setAvatar] = useState(randomAvatar());
 const [validated, setValidated] = useState(false);
 const [form, setForm] = useState({});
 const { toaster, setToaster } = useContext(Context);
 const user = getUser();
 const handleSubmit = (event) => {
   // Logic to handle form submission
 };
...

现在让我们添加Form UI:

src/component/comments/CreateComment.jsx

...
return (
   <Form
     className="d-flex flex-row justify-content-between"
     noValidate
     validated={validated}
     onSubmit={handleSubmit}
   >
     <Image
       src={avatar}
       roundedCircle
       width={48}
       height={48}
       className="my-2"
     />
     <Form.Group className="m-3 w-75">
       <Form.Control
         className="py-2 rounded-pill border-primary"
         type="text"
         placeholder="Write a comment"
         value={form.body}
         name="body"
         onChange={(e) => setForm({ ...form,
                                   body: e.target.value })}
       />
     </Form.Group>
     <div className="m-auto">
       <Button
         variant="primary"
         onClick={handleSubmit}
         disabled={form.body === undefined}
         size="small"
       >
         Comment
       </Button>
     </div>
   </Form>
 );
...

UI 添加后,我们可以编写handeSubmit方法:

src/component/comments/CreateComment.jsx

...
 const handleSubmit = (event) => {
   event.preventDefault();
   const createCommentForm = event.currentTarget;
   if (createCommentForm.checkValidity() === false) {
     event.stopPropagation();
   }
   setValidated(true);
   const data = {
     author: user.id,
     body: form.body,
     post: postId,
   };
   axiosService
     .post(`/post/${postId}/comment/`, data)
     .then(() => {
       setForm({ ...form, body: "" });
       setToaster({
         type: "success",
         message: "Comment posted successfully🚀",
         show: true,
         title: "Comment!",
       });
       refresh();
     })
     .catch(() => {
       setToaster({
         type: "danger",
         message: "",
         show: true,
         title: "An error occurred.!",
       });
     });
 };
...

CreatePost组件类似,我们在表单的有效性检查上做了工作,同时也向/post/${postId}/comment/端点发送请求。然后,根据响应,我们显示 toast 并清理表单。让我们测试表单并使用 React 添加第一条评论:

src/pages/SinglePost.jsx

...
 return (
   <Layout hasNavigationBack>
     {post.data ? (
       <Row className="justify-content-center">
         <Col sm={8}>
           <Post post={post.data} refresh={post.mutate}
             isSinglePost />
           <CreateComment postId={post.data.id}
             refresh={comments.mutate} />
         </Col>
       </Row>
     ) : (
       <div>Loading...</div>
     )}
   </Layout>
 );
...

你应该得到类似的结果:

图 9.5 – 创建评论

图 9.5 – 创建评论

在前面的段落中,我们创建了一个页面来显示关于帖子的信息,从而允许我们添加一个显示表单的模态,用于创建与该帖子相关的新的评论。

现在,我们需要显示创建的评论。

列出评论

我们可以创建评论,但看不到它们。在src/components/comments中创建一个名为CreateComment.jsx的新文件。这个文件将包含用于显示评论详细信息的Comment组件的代码。以下是Comment组件的线框图:

图 9.6 – 评论组件的线框图

图 9.6 – 评论组件的线框图

让我们继续编写代码。让我们首先添加CreateComment函数和导入,并定义我们将在这个组件中使用的状态:

src/components/comments/CreateComment.jsx

import React, { useState, useContext } from "react";
import { format } from "timeago.js";
import { Image, Card, Dropdown } from "react-bootstrap";
import { randomAvatar } from "../../utils";
import axiosService from "../../helpers/axios";
import { getUser } from "../../hooks/user.actions";
import UpdateComment from "./UpdateComment";
import { Context } from "../Layout";
import MoreToggleIcon from "../MoreToggleIcon";
function Comment(props) {
 const { postId, comment, refresh } = props;
 const { toaster, setToaster } = useContext(Context);
 const user = getUser();
 const handleDelete = () => {
    // Handle the deletion of a comment
 };
 return (
   <Card className="rounded-3 my-2">
    // Code for the comment card
   </Card>
 );
}
export default Comment;

我们有必要的导入。让我们先从 UI 开始。它的结构有点像Post组件:

src/components/comments/CreateComment.jsx

...
 return (
   <Card className="rounded-3 my-2">
     <Card.Body>
       <Card.Title className="d-flex flex-row
         justify-content-between">
         <div className="d-flex flex-row">
           <Image
             src={randomAvatar()}
             roundedCircle
             width={48}
             height={48}
             className="me-2 border border-primary
                        border-2"
           />
           <div className="d-flex flex-column
               justify-content-start
               align-self-center mt-2">
             <p className="fs-6 m-0">{comment.author.name}
             </p>
             <p className="fs-6 fw-lighter">
               <small>{format(comment.created)}</small>
             </p>
           </div>
         </div>
         {user.name === comment.author.name && (
           <div>
             <Dropdown>
               <Dropdown.Toggle
                 as={MoreToggleIcon}></Dropdown.Toggle>
               <Dropdown.Menu>
                 <Dropdown.Item>
                   Modify
                 </Dropdown.Item>
                 <Dropdown.Item onClick={handleDelete}
                   className="text-danger">
                   Delete
                 </Dropdown.Item>
               </Dropdown.Menu>
             </Dropdown>
           </div>
         )}
       </Card.Title>
       <Card.Text>{comment.body}</Card.Text>
     </Card.Body>
   </Card>
 );
...

Comment组件的 UI 已经准备好了。让我们看看在帖子页面上的结果:

图 9.7 – 帖子上的评论列表

图 9.7 – 帖子上的评论列表

我们还在每个组件的右上角有更多的点,这意味着我们需要实现删除和修改评论的功能。让我们添加删除功能。

删除评论

更多点菜单提供了两个选项:删除和修改评论。让我们先添加代码和操作以删除评论。函数已经声明,我们只需要添加逻辑:

Src/components/comments/CreateComment.jsx

...
 const handleDelete = () => {
   axiosService
     .delete(`/post/${postId}/comment/${comment.id}/`)
     .then(() => {
       setToaster({
         type: "danger",
         message: "Comment deleted 🚀",
         show: true,
         title: "Comment Deleted",
       });
       refresh();
     })
     .catch((err) => {
       setToaster({
         type: "warning",
         message: "Comment deleted 🚀",
         show: true,
         title: "Comment Deleted",
       });
     });
 };
...

handleDelete函数中,我们使用axios/post/${postId}/comment/${comment.id}/发送请求以删除评论。根据 HTTP 请求的结果,我们显示带有正确信息的吐司。一旦你添加完代码,让我们测试结果:

图 9.8 – 删除评论

图 9.8 – 删除评论

在我们的 React 应用程序中,现在可以删除评论了。让我们继续添加修改评论的功能。

更新评论

更新评论将与在UpdatePost.jsx文件中所做的工作类似。然而,我将协助你编写这个评论功能的特性。我们还要为我们的评论添加一个令人兴奋的元素:点赞和取消点赞评论,但作为一个练习。让我们专注于评论的修改。为此,我们需要创建一个模态框。

添加更新评论的模态框

src/components/comments目录内,创建一个名为UpdateComment.jsx的文件。此文件将包含模态框和允许用户更新评论的表单:

src/components/comments/UpdateComment.jsx

import React, { useState, useContext } from "react";
import { Button, Modal, Form, Dropdown } from "react-bootstrap";
import axiosService from "../../helpers/axios";
import { Context } from "../Layout";
function UpdateComment(props) {
 const { postId, comment, refresh } = props;
 const [show, setShow] = useState(false);
 const [validated, setValidated] = useState(false);
 const [form, setForm] = useState({
   author: comment.author.id,
   body: comment.body,
   post: postId
 });
 const { toaster, setToaster } = useContext(Context);
 const handleSubmit = (event) => {
   // handle the modification of a comment
 };
 return (
   <>
     <Dropdown.Item
       onClick={handleShow}>Modify</Dropdown.Item>
     // Adding the Modal here
   </>
 );
}
export default UpdateComment;

我们正在进行所需的导入,并定义在修改触发时将使用和更新的状态。请注意,我们还传递了postIdcomment对象作为props。第一个是用于端点的;第二个也是用于端点的,但最重要的是,为了有一个默认值,我们需要在表单中显示它,以便用户进行修改。

让我们添加模态 UI:

src/components/comments/UpdateComment.jsx

…
 return (
   <>
     <Dropdown.Item onClick={handleShow}>Modify
     </Dropdown.Item>
     <Modal show={show} onHide={handleClose}>
       <Modal.Header closeButton className="border-0">
         <Modal.Title>Update Post</Modal.Title>
       </Modal.Header>
       <Modal.Body className="border-0">
         <Form noValidate validated={validated}
           onSubmit={handleSubmit}>
           <Form.Group className="mb-3">
             <Form.Control
               name="body"
               value={form.body}
               onChange={(e) => setForm({ ...form,
                 body: e.target.value })}
               as="textarea"
               rows={3}
             />
           </Form.Group>
         </Form>
       </Modal.Body>
       <Modal.Footer>
         <Button variant="primary" onClick={handleSubmit}>
           Modify
         </Button>
       </Modal.Footer>
     </Modal>
   </>
 );
…

UI 准备就绪后,我们现在可以编写handleSubmit函数:

src/components/comments/UpdateComment.jsx

…
 const handleSubmit = (event) => {
   event.preventDefault();
   const updateCommentForm = event.currentTarget;
   if (updateCommentForm.checkValidity() === false) {
     event.stopPropagation();
   }
   setValidated(true);
   const data = {
     author: form.author,
     body: form.body,
     post: postId
   };
   axiosService
     .put(`/post/${postId}/comment/${comment.id}/`, data)
     .then(() => {
       handleClose();
       setToaster({
         type: "success",
         message: "Comment updated 🚀",
         show: true,
         title: "Success!",
       });
       refresh();
     })
     .catch((error) => {
       setToaster({
         type: "danger",
         message: "An error occurred.",
         show: true,
         title: "Comment Error",
       });
     });
 };
...

让我们将此组件导入并添加到Comment.jsx文件中:

src/components/comments/Comment.jsx

…
         {user.name === comment.author.name && (
           <div>
             <Dropdown>
               <Dropdown.Toggle as={MoreToggleIcon}>
               </Dropdown.Toggle>
               <Dropdown.Menu>
                 <UpdateComment
                   comment={comment}
                   refresh={refresh}
                   postId={postId}
                 />
                 <Dropdown.Item onClick={handleDelete}
                   className="text-danger">
                   Delete
                 </Dropdown.Item>
               </Dropdown.Menu>
             </Dropdown>
           </div>
         )}
       </Card.Title>
       <Card.Text>{comment.body}</Card.Text>
     </Card.Body>
   </Card>
…

添加此段代码后,当你点击更多菜单中的修改选项时,会出现一个模态框,如下图所示:

图 9.9 – 修改评论模态框

图 9.9 – 修改评论模态框

如果修改提交成功,页面上方将出现一个吐司:

图 9.10 – 显示成功修改评论的吐司

图 9.10 – 显示成功修改评论的吐司

太好了!我们已经完成了对评论功能的 CRUD 操作。对于评论来说,一个令人兴奋的功能是能够点赞。这与我们对帖子所做的工作类似。这是本章的下一步,也是一个练习。

点赞评论

点赞功能添加到评论功能需要修改 Django API 和一些需要添加到 React 应用程序中的代码。首先,让我提供最终结果:

图 9.11 – 带有点赞功能和点赞计数的评论

图 9.11 – 带有点赞功能和点赞计数的评论

这里是此功能要求列表:

  • 用户可以看到评论上的点赞数量

  • 用户可以点赞一条评论

  • 用户可以从评论中移除点赞

这将需要对 Django API 进行一些调整。请随意从我们为帖子功能所做的工作中获取灵感。

祝你练习顺利。你可以在 github.com/PacktPublishing/Full-stack-Django-and-React/blob/main/social-media-react/src/components/comments/Comment.jsx 找到解决方案。

在将 点赞 功能添加到评论后,我们现在终于可以给 React 应用的个人资料添加 CRUD 操作了。我们将创建一个个人资料页面,并允许用户编辑他们个人资料中的信息。我们还将启用用户更新他们的头像,并为用户设置默认头像图片。

摘要

在本章中,我们专注于为评论功能添加 CRUD 操作。我们学习了如何使用 react-router Hooks 来检索参数并在代码中使用它们。我们还添加了 useStateuseContext Hooks 以及它们如何影响 React 中的状态。我们还学习了如何创建下拉组件,如何使用自定义的吐司组件,以及如何调整组件以满足某些要求。

在下一章中,我们将专注于对用户个人资料的 CRUD 操作,并且我们还将学习如何上传个人资料图片。

问题

  1. useParams 的用法是什么?

  2. 你如何在 React 中编写一个支持参数传递的路由?

  3. useContext Hook 的用途是什么?

第十章:用户个人资料

一个社交媒体应用应允许用户查看其他用户的个人资料。从另一个角度来看,它还应允许认证用户编辑他们的信息,例如他们的姓氏、名字和头像。

在本章中,我们将专注于在用户端添加 CRUD 功能。我们将构建一个页面来可视化用户个人资料,并构建一个允许用户编辑他们信息的页面。本章将涵盖以下主题:

  • 在主页上列出个人资料

  • 在个人资料页面上显示用户信息

  • 编辑用户信息

技术要求

确保您的机器上已安装并配置了 VS Code 和更新的浏览器。您可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap10找到本章中使用的所有代码文件。

在主页上列出个人资料

在构建显示用户信息和允许修改用户信息的页面和组件之前,我们需要在主页上添加一个组件来列出一些个人资料,如下所示:

图 10.1 – 列出个人资料

图 10.1 – 列出个人资料

按照以下步骤添加主页上列出个人资料的组件:

  1. src/components文件中,创建一个名为profile的新目录。这个目录将包含所有与用户或个人资料相关的组件代码。

  2. 在新创建的目录中,创建一个名为ProfileCard.jsx的文件,并添加以下内容:

src/components/profile/ProfileCard.jsx

import React from "react";
import { Card, Button, Image } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
function ProfileCard(props) {
 return (
    // JSX code here
 );
}
export default ProfileCard;

ProfileCard组件将被用来显示个人资料信息并重定向用户到个人资料页面。

  1. 接下来,我们将添加有关导航到个人资料页面和 props 对象解构的代码逻辑:

src/components/profile/ProfileCard.jsx

...
function ProfileCard(props) {
 const navigate = useNavigate();
 const { user } = props;
 const handleNavigateToProfile = () => {
   navigate(`/profile/${user.id}/`)
 };
 return (
    // JSX Code
 );
}
export default ProfileCard;

在前面的代码中,我们从 props 中检索了用户对象,并添加了一个处理导航到用户个人资料页面的函数。

  1. 接下来,让我们编写将向用户显示信息的 JSX:

src/components/profile/ProfileCard.jsx

...
 return (
   <Card className="border-0 p-2">
     <div className="d-flex ">
       <Image
         src={user.avatar}
         roundedCircle
         width={48}
         height={48}
         className=
           "my-3 border border-primary border-2"
       />
       <Card.Body>
         <Card.Title
           className="fs-6">{user.name}</Card.Title>
         <Button variant="primary"
           onClick={handleNavigateToProfile}>
           See profile
         </Button>
       </Card.Body>
     </div>
   </Card>
 );
}
export default ProfileCard;

ProfileCard组件已经编写完成。我们现在可以将其导入到Home.jsx页面并使用它。但在那之前,我们需要从 API 中检索五个个人资料,并遍历结果以实现所需的显示:

src/pages/Home.jsx

...
import CreatePost from "../components/posts/CreatePost";
import ProfileCard from "../components/profile/ProfileCard";
function Home() {
...
 const profiles = useSWR("/user/?limit=5", fetcher);
...
 return (
   <Layout>
     <Row className="justify-content-evenly">
       ...
       <Col sm={3} className="border rounded py-4
           h-50">
         <h4 className="font-weight-bold text-center">
           Suggested people</h4>
         <div className="d-flex flex-column">
           {profiles.data &&
             profiles.data.results.map((profile,
                                        index) => (
               <ProfileCard key={index} user={profile}
               />
             ))}
         </div>
       </Col>
     </Row>
   </Layout>
 );
}
export default Home;

在前面的代码中,只有当profiles.data对象不为 null 或 undefined 时,才会显示个人资料。这就是为什么我们编写了profiles.data && profiles.data.results.map()内联 JSX 条件。

  1. 完成后,重新加载主页,您将有一个新的组件可用,最多列出五个个人资料。

尝试点击查看个人资料按钮。您将被重定向到一个空白页面。这是正常的,因为我们还没有为个人资料页面编写路由。

在下一节中,我们将创建用于显示个人资料信息的组件,如下所示:

图 10.2 – 用户个人资料页面

图 10.2 – 用户资料页面

我们也将允许用户编辑他们的信息,如下所示:

图 10.3 – 用户编辑表单和页面

图 10.3 – 用户编辑表单和页面

在他们的资料页面上显示用户信息

在本节中,我们将创建一个资料页面来显示用户信息。我们将构建一个组件来显示用户详情和与该用户相关的帖子,同时我们还将创建一个显示编辑用户信息表单的页面。

在开始构建用户资料页面之前,我们必须创建一些组件。在资料页面上,我们不仅显示信息,还显示用户创建的帖子列表。让我们先编写ProfileDetails.jsx组件(图 10.4):

图 10.4 – ProfileDetails 组件

图 10.4 – ProfileDetails 组件

这里是帮助您了解组件结构的线框图:

图 10.5 – ProfileDetails 组件的线框图

图 10.5 – ProfileDetails 组件的线框图

ProfileDetails组件中,我们正在显示一些头像。在这个项目阶段,是时候摆脱randomAvatar函数了。它在这个项目阶段一直很有用,但我们正在发出很多请求,应用程序中的某些状态变化只是再次调用该函数,返回另一个随机图像,这不是应用程序用户可能希望看到的。

让我们开始使用用户对象上的头像字段值,但在那之前,我们必须配置 Django 以处理媒体上传和用户对象上的头像字段。

社交媒体应用程序使用avatar字段,它代表一个浏览器可以发出请求并接收图像的文件链接。Django 支持文件上传;我们只需添加一些配置使其生效。

在项目的settings.py文件中,在项目末尾添加以下行:

CoreRoot/settings.py

…
MEDIA_URL'= '/med'a/'
MEDIA_ROOT = BASE_DIR'/ 'uplo'ds'

MEDIA_URL设置允许我们编写用于检索上传文件的 URL。MEDIA_ROOT设置告诉 Django 在哪里存储文件,并在返回文件 URL 时检查上传文件。在本项目中,头像字段将具有此 URL,例如:http://localhost:8000/media/user_8380ca50-ad0f-4141-88ef-69dc9b0707ad/avatar-rogemon.png

为了使此配置生效,您需要在 Django 项目的根目录下创建一个名为uploads的目录。您还需要安装 Pillow 库,它包含所有基本的图像处理功能工具:

pip install pillow

之后,让我们稍微修改一下用户模型中的头像字段。在core/user/models.py文件中,在UserManager管理类之前添加一个函数:

core/user/models.py

...
def user_directory_path(instance, filename):
    # file will be uploaded to
       MEDIA_ROOT/user_<id>/<filename>
    return 'user_{0}/{1}'.format(instance.public_id,
                                 filename)
...

此函数将帮助重写文件上传的路径。而不是直接进入 uploads 目录,头像将根据用户存储。这可以帮助更好地组织系统中的文件。在添加此函数后,我们可以告诉 Django 使用它作为默认上传路径:

core/user/models.py

...
class User(AbstractModel, AbstractBaseUser, PermissionsMixin):
...
    avatar = models.ImageField(
        null=True, blank=True,
             upload_to=user_directory_path)
...

在 Django 中,ImageField 字段用于在数据库中存储图像文件。它是 FileField 的子类,FileField 是用于存储文件的通用字段,因此它具有 FileField 的所有属性以及一些特定于图像的附加属性。upload_to 属性指定了图像文件将存储的目录。

现在,运行 makemigrations 命令,并确保将更改迁移到数据库中:

python manage.py makemigrations
python manage.py migrate

完成此配置后,我们的 API 可以接受用户上传头像。然而,一些用户可能没有头像,而我们从前端处理这个问题的方式相当糟糕。让我们设置一个默认头像,用于没有头像的用户。

配置默认头像

要配置默认头像,请按照以下步骤操作:

  1. 在 Django 项目的 settings.py 文件中,在文件末尾添加以下行:

CoreRoot/settings.py

...
DEFAULT_AVATAR_URL = "https://avatars.dicebear.com/api/identicon/.svg"

头像图片如下所示:

图像 10.6:默认图片

  1. 一旦你将 DEFAULT_AVATAR_URL 添加到 settings.py 文件中,我们将稍微修改 UserSerializer 的表示方法,以便在头像字段为空时默认返回 DEFAULT_AVATAR_URL 值:

Core/user/serializers.py

...
from django.conf import settings
class UserSerializer(AbstractSerializer):
...
    def to_representation(self, instance):
        representation =
          super().to_representation(instance)
        if not representation['avatar']:
            representation['avatar'] =
              settings.DEFAULT_AUTO_FIELD
            return representation
        if settings.DEBUG:  # debug enabled for dev
            request = self.context.get('request')
            representation['avatar'] =
              request.build_absolute_uri(
                representation['avatar'])
        return representation

让我们解释一下前面代码块中我们在做什么。首先,我们需要检查头像值是否存在。如果不存在,我们将返回默认头像。默认情况下,Django 不返回带有域的实际文件路径。这就是为什么在这种情况下,如果我们处于开发环境,我们将返回头像的绝对 URL。在本书的最后一部分,我们将部署应用程序到生产服务器,然后我们将使用 AWS S3 进行文件存储。

在后端完成修复后,我们现在可以放心地修改前端应用程序,包括头像字段。这相当简单,只需要一点重构。从 React 应用程序中删除 randomAvatar 函数代码,并用 user.avatarpost.author.avatarcomment.author.avatar 替换值,具体取决于文件和组件。

  1. 完成那些小配置后,检查主页;你应该会有一个类似的结果。

Figure 10.7 – 带有默认头像的主页

图像 10.7 – 带有默认头像的主页

太好了!让我们继续创建个人资料页面,以便我们的 Django 应用程序准备好接受文件上传。

编写 ProfileDetails 组件

要创建ProfileDetails组件,我们必须创建包含此组件代码的文件,添加导航逻辑,编写 UI(JSX),并在个人资料页面上导入组件:

  1. src/components/profile目录下,创建一个名为ProfileDetail.jsx的新文件。这个文件将包含ProfileDetails组件的代码:

src/components/profile/ProfileDetails.jsx

import React from "react";
import { Button, Image } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
function ProfileDetails(props) {
 return (
// JSX code here
 );
}
export default ProfileDetails;
  1. 在这里,我们只需要解构 props 对象来检索用户对象,声明 navigate 变量以使用useNagivate钩子,并最终处理用户对象为 undefined 或 null 的情况:

src/components/profile/ProfileDetaisl.jsx

...
function ProfileDetails(props) {
 const { user } = props;
 const navigate = useNavigate();
 if (!user) {
   return <div>Loading...</div>;
 }
 return (
   // JSX Code here
 );
}
export default ProfileDetails;
  1. 现在我们可以自信地编写 JSX 逻辑:

src/components/profile/ProfileDetails.jsx

...
 return (
   <div>
     <div className="d-flex flex-row border-bottom
       p-5">
       <Image
         src={user.avatar}
         roundedCircle
         width={120}
         height={120}
         className="me-5 border border-primary
                    border-2"
       />
       <div className="d-flex flex-column
        justify-content-start align-self-center mt-2">
         <p className="fs-4 m-0">{user.name}</p>
         <p className="fs-5">{user.bio ? user.bio :
           "(No bio.)"}</p>
         <p className="fs-6">
           <small>{user.posts_count} posts</small>
         </p>
         <Button
           variant="primary"
           size="sm"
           className="w-25"
           onClick={() =>
             navigate(`/profile/${user.id}/edit/`)}
         >
           Edit
         </Button>
       </div>
     </div>
   </div>
 );
  1. 现在组件编写完毕,请在src/pages目录下创建一个名为Profile.jsx的新文件。这个文件将包含个人资料页面的代码和逻辑:

src/pages/Profile.jsx

import React from "react";
import { useParams } from "react-router-dom";
import Layout from "../components/Layout";
import ProfileDetails from "../components/profile/ProfileDetails";
import useSWR from "swr";
import { fetcher } from "../helpers/axios";
import { Post } from "../components/posts";
import { Row, Col } from "react-bootstrap";
function Profile() {
  return (
    // JSX CODE
  );
}
export default Profile;
  1. 让我们添加获取用户和用户帖子的逻辑。不需要创建另一个Post组件,因为src/components/Post.jsx中的相同Post组件将被用来列出由个人资料创建的帖子:

src/pages/Profile.jsx

...
function Profile() {
  const { profileId } = useParams();
  const user = useSWR(`/user/${profileId}/`, fetcher);
  const posts = useSWR(`/post/?author__public_id=${profileId}`, fetcher, {
       refreshInterval: 20000
   });
...
  1. 完成后,我们现在可以编写 UI 逻辑:

src/pages/Profile.jsx

...
  return (
    <Layout hasNavigationBack>
      <Row className="justify-content-evenly">
        <Col sm={9}>
          <ProfileDetails user={user.data}/>
          <div>
            <Row className="my-4">
              {posts.data?.results.map((post, index)
                => (
                <Post key={index} post={post}
                  refresh={posts.mutate} />
              ))}
            </Row>
          </div>
        </Col>
      </Row>
    </Layout>
  );
}
...
  1. 太好了!现在让我们在App.js文件中注册这个页面:

src/App.js

…
<Route
  path="/profile/:profileId/"
  element={
    <ProtectedRoute>
      <Profile />
    </ProtectedRoute>
  }
/>
…
  1. 不要忘记在Navbar.jsx文件中添加链接

src/components/Navbar.jsx

...
    <NavDropdown.Item as={Link} to=
      {`/profile/${user.id}/`}>Profile
    </NavDropdown.Item>
    <NavDropdown.Item onClick={handleLogout}>Logout
    </NavDropdown.Item>
  </NavDropdown>
</Nav>
...
  1. 太好了!你现在可以点击查看个人资料按钮或直接点击导航栏的下拉菜单来访问个人资料页面:

图 10.8 – 一个随机的个人资料页面

图 10.8 – 一个随机的个人资料页面

在个人资料页面准备就绪后,我们可以继续创建包含编辑用户信息表单的页面。

编辑用户信息

通过为useUserActions钩子添加一个新方法来编辑用户信息通过 API。然后,我们将创建一个用于编辑用户信息的表单。最后,我们将编辑表单组件集成到EditUser页面上。

让我们从向useUserActions钩子添加一个新方法开始。

向 useUserActions 添加编辑方法

src/hooks/user.actions.js文件中,我们将向useUserActions钩子添加另一个方法。这个函数将处理对 API 的patch请求。由于我们在localStorage中保存用户对象,如果请求成功,我们将更新对象的值:

src/hooks/user.actions.js

function useUserActions() {
  const navigate = useNavigate();
  const baseURL = "http://localhost:8000/api";
  return {
    login,
    register,
    logout,
    edit
  };
...
  // Edit the user
  function edit(data, userId) {
    return axiosService.patch(`${baseURL}/user/${userId}/`,
                               data).then((res) => {
      // Registering the account in the store
      localStorage.setItem(
        "auth",
        JSON.stringify({
          access: getAccessToken(),
          refresh: getRefreshToken(),
          user: res.data,
        })
      );
    });
  }
...
}

在编写了edit函数之后,我们可以自信地开始创建用于编辑用户信息的表单。

UpdateProfileForm 组件

src/components/UpdateProfileForm.jsx中,创建一个名为UpdateProfileForm.jsx的文件。这个文件将包含用于编辑用户信息的组件的代码:

src/components/UpdateProfileForm.jsx

import React, { useState, useContext } from "react";
import { Form, Button, Image } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { useUserActions } from "../../hooks/user.actions";
import { Context } from "../Layout";
function UpdateProfileForm(props) {
  return (
    // JSX Code
  );
}
export default UpdateProfileForm;

让我们从从 props 中检索用户对象并添加处理表单所需的钩子开始:

src/components/UpdateProfileForm.jsx

...
function UpdateProfileForm(props) {
  const { profile } = props;
  const navigate = useNavigate();
  const [validated, setValidated] = useState(false);
  const [form, setForm] = useState(profile);
  const [error, setError] = useState(null);
  const userActions = useUserActions();
  const [avatar, setAvatar] = useState();
  const { toaster, setToaster } = useContext(Context);
...

下一步是编写handleSubmit方法。此方法应处理表单的有效性、更新信息的请求以及根据结果显示的内容:

src/components/UpdateProfileForm.jsx

...
const handleSubmit = (event) => {
  event.preventDefault();
  const updateProfileForm = event.currentTarget;
  if (updateProfileForm.checkValidity() === false) {
    event.stopPropagation();
  }
  setValidated(true);
  const data = {
    first_name: form.first_name,
    last_name: form.last_name,
    bio: form.bio,
  };
  const formData = new FormData();
}
...

由于我们将在发送到服务器的数据中包含一个文件,我们正在使用FormData对象。FormData对象是创建将发送到服务器的数据包的常用方式。它提供了一个简单且易于构造一组键/值对的方法,代表表单字段的名称及其值。

在我们项目的案例中,我们需要将数据变量中的数据传递给formData对象:

src/components/UpdateProfileForm.jsx

...
const formData = new FormData();
Object.keys(data).forEach((key) => {
    if (data[key]) {
      formData.append(key, data[key]);
    }
});
...

Object构造函数提供了一个keys方法,它返回 JavaScript 对象中的键列表。然后我们使用forEach方法遍历keys数组,检查data[key]的值是否不为 null,然后将数据对象中的值追加到formData对象中。我们还需要为头像字段添加一个情况:

src/components/UpdateProfileForm.jsx

...
const formData = new FormData();
// Checking for null values in the form and removing them.
Object.keys(data).forEach((key) => {
    if (data[key]) {
      formData.append(key, data[key]);
    }
});
if (avatar) {
  formData.append("avatar", avatar);
}
...

我们现在可以转到编辑操作:

src/components/UpdateProfileForm.jsx

...
userActions
  .edit(formData, profile.id)
  .then(() => {
    setToaster({
      type: "success",
      message: "Profile updated successfully 🚀",
      show: true,
      title: "Profile updated",
    });
    navigate(-1);
  })
  .catch((err) => {
    if (err.message) {
      setError(err.request.response);
    }
  });
...

这里没有什么复杂的。就像我们以前在 API 上的其他请求中所做的那样。现在让我们转到表单。表单将包含头像字段,如名字、姓氏和个人简介。这些字段是用户唯一需要更新的信息。让我们先编写头像字段:

src/components/UpdateProfileForm.jsx

...
return (
  <Form
    id="registration-form"
    className="border p-4 rounded"
    noValidate
    validated={validated}
    onSubmit={handleSubmit}
  >
    <Form.Group className="mb-3 d-flex flex-column">
      <Form.Label className="text-center">Avatar
      </Form.Label>
      <Image
        src={form.avatar}
        roundedCircle
        width={120}
        height={120}
        className="m-2 border border-primary border-2
                   align-self-center"
      />
      <Form.Control
        onChange={(e) => setAvatar(e.target.files[0])}
        className="w-50 align-self-center"
        type="file"
        size="sm"
      />
      <Form.Control.Feedback type="invalid">
        This file is required.
      </Form.Control.Feedback>
    </Form.Group>
    ...
  </Form>
);

太好了!让我们添加姓氏和名字的字段:

src/components/UpdateProfileForm.jsx

...
<Form.Group className="mb-3">
  <Form.Label>First Name</Form.Label>
  <Form.Control
    value={form.first_name}
    onChange={(e) => setForm({ ...form, first_name:
                              e.target.value })}
    required
    type="text"
    placeholder="Enter first name"
  />
  <Form.Control.Feedback type="invalid">
    This file is required.
  </Form.Control.Feedback>
</Form.Group>
<Form.Group className="mb-3">
  <Form.Label>Last name</Form.Label>
  <Form.Control
    value={form.last_name}
    onChange={(e) => setForm({ ...form, last_name:
                              e.target.value })}
    required
    type="text"
    placeholder="Enter last name"
  />
  <Form.Control.Feedback type="invalid">
    This file is required.
  </Form.Control.Feedback>
</Form.Group>
...

最后,让我们添加个人简介字段和提交按钮:

src/components/UpdateProfileForm.jsx

...
<Form.Group className="mb-3">
  <Form.Label>Bio</Form.Label>
  <Form.Control
    value={form.bio}
    onChange={(e) => setForm({ ...form, bio: e.target.value })}
    as="textarea"
    rows={3}
    placeholder="A simple bio ... (Optional)"
  />
</Form.Group>
<div className="text-content text-danger">{error && <p>{error}</p>}</div>
<Button variant="primary" type="submit">
  Save changes
</Button>
...

太好了!UpdateProfileForm组件已经编写完成,我们可以使用它来创建EditProfile.jsx页面。

创建编辑个人资料页面

src/pages/目录下,创建一个名为EditProfile.jsx的新文件。此文件将包含显示编辑用户信息的表单的页面代码:

src/pages/EditProfile.jsx

import React from "react";
import { useParams } from "react-router-dom";
import useSWR from "swr";
import Layout from "../components/Layout";
import UpdateProfileForm from "../components/profile/UpdateProfileForm";
import { fetcher } from "../helpers/axios";
import { Row, Col } from "react-bootstrap";
function EditProfile() {
  return (
    //JSX code
  );
}
export default EditProfile;

添加了所需的导入后,我们现在可以添加获取逻辑和 UI:

src/pages/EditProfile.jsx

...
function EditProfile() {
  const { profileId } = useParams();
  const profile = useSWR(`/user/${profileId}/`, fetcher);
  return (
    <Layout hasNavigationBack>
      {profile.data ? (
        <Row className="justify-content-evenly">
          <Col sm={9}>
            <UpdateProfileForm profile={profile.data} />
          </Col>
        </Row>
      ) : (
        <div>Loading...</div>
      )}
    </Layout>
  );
}
...

EditProfile函数中,我们计划检索将用于获取最新用户信息的profileId,并将响应传递给UpdateProfileForm组件。自然地,我们返回App.js文件:

src/App.jsx

...
<Route
  path="/profile/:profileId/edit/"
  element={
    <ProtectedRoute>
      <EditProfile />
    </ProtectedRoute>
  }
/>
...

现在,前往您的个人资料并点击编辑按钮。更改信息并添加头像图片以确保一切正常工作。

React 应用程序几乎完成了。我们已经实现了认证、帖子、评论和新用户的 CRUD 操作。现在,是时候关注我们组件的质量和可维护性了。

摘要

在本章中,我们在 React 应用程序中为用户添加了 CRUD 操作。我们探讨了在 Django 中处理媒体上传的强大和简单性,以及如何创建一个可以接受文件上传到远程服务器的表单。我们还为 React 应用程序添加了新的组件,以实现更好的导航和探索其他个人资料。我们已完成应用程序大多数功能的实现。

在下一章中,我们将学习如何为 React 前端应用程序编写测试。

问题

  1. 什么是 formData 对象?

  2. Django 中的 MEDIA_URL 设置用途是什么?

  3. Django 中的 MEDIA_ROOT 设置用途是什么?

第十一章:React 组件的有效 UI 测试

我们已经在第五章**,测试 REST API中介绍了使用 Python 和 Django 进行测试。在本章中,情境不同,我们将使用 JavaScript 和 React 来测试我们设计和实现的前端组件。本章将展示在前端应用程序中应该测试什么以及如何为 React UI 组件编写测试。

在本章中,我们将涵盖以下主题:

  • React 中的组件测试

  • Jest 和React 测试库RTL

  • 测试表单组件

  • 测试post组件

  • 快照测试

技术要求

确保你的机器上已安装并配置了 VS Code 和更新的浏览器。你可以从本章找到代码:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap11

React 中的组件测试

我们已经了解到前端是应用程序的客户端部分。关于我们在第五章**,测试 REST API中编写的测试,在我们的 Django 应用程序中,我们主要测试了数据库是否存储了传递给视图集、序列化和模型的数据。然而,我们没有测试用户界面。

作为一名 React 开发者,你可能正在思考:在我的前端应用程序中我应该测试什么?好吧,让我们通过了解为什么需要前端测试以及需要测试什么来回答这个问题。

测试前端的重要性

在开发应用程序时,确保你的应用程序在生产环境中按预期工作是很重要的。

前端也代表了用户将用来与你的后端交互的界面。为了良好的用户体验,编写确保你的组件按预期行为的测试是至关重要的。

在你的 React 应用程序中应该测试什么

如果你来自后端视角,你可能在你的前端应用程序中测试什么方面可能会有些困惑。从基本的角度来看,它与测试后端并没有不同。如果你的应用程序中有类或方法,你可以编写测试。前端测试包括测试 UI 的不同方面,如格式、可见文本、图形以及应用程序的功能部分,如按钮、表单或可点击链接。

现在,区别在于你的 React 前端是由 UI 组件构成的,这些组件接收属性以向用户显示数据。React 生态系统提供了测试工具,这些工具可以轻松帮助你为你的组件编写测试。

在下一节中,我们将从对 Jest 和 RTL 的简要介绍开始,然后我们将为我们的认证表单编写测试。

Jest、RTL 和固定数据

Jest 是一个用于编写、运行和结构化测试的 JavaScript 框架。它包含所有检查代码覆盖率、轻松模拟函数和导入函数以及编写简单且出色的异常所需的工具。RTL 是一个用于实际测试 React 应用的库。它专注于从用户体验的角度测试组件,而不是测试 React 组件本身的实现和逻辑。

重要提示

在编写测试时,您通常会需要确保某些值或变量满足某些条件。这在本书的 第五章**,测试 REST API 中已经完成,使用 assert 在使用 pytest 编写 Django 应用程序的测试时。与 Jest 一起工作时,术语从断言变为异常。在使用 Jest 进行前端测试时,我们期望值满足条件。例如,如果用户输入并点击一个将重置表单的按钮,我们期望在按钮上的点击动作之后表单被重置。

RTL(右对齐文本)与 Jest 并未分离,因为您需要两者来为您的前端应用程序编写测试。Jest 将帮助您编写测试块,而 RTL 将提供选择组件、渲染组件和触发常见用户事件(如点击和输入)的工具。这些工具在创建 React 项目时已默认安装,因此无需添加其他包。

我们需要的唯一包是 faker.js 和 JavaScript 的 uuid 包来生成 UUID4 标识符。Faker 是一个用于生成虚假但逼真数据的 JavaScript 包。在 React 项目中,使用以下命令将包作为开发依赖项安装:

yarn add @faker-js/faker uuid –dev

在安装了这些包之后,我们现在可以为下一行中将要测试的组件添加一些重要的固定值。

编写测试固定值

src/helpers 目录下,创建一个名为 fixtures 的新目录。此目录将包含包含函数的 JavaScript 文件,这些函数返回可用于测试的固定值。

我们将首先编写用户的固定值。因此,在 fixtures 目录下,创建一个名为 user.js 的新文件。此文件将包含返回用户对象真实数据的函数的代码。让我们从从 faker.jsuuid 包中导入函数以创建固定值开始:

src/helpers/fixtures/user.js

import { faker } from "@faker-js/faker";
import { v4 as uuid4 } from "uuid";
function userFixtures() {
...
}
export default userFixtures;

在编写了导入和 userFixtures 函数的结构之后,我们现在可以返回对象固定值:

src/helpers/fixtures/user.js

...
function userFixtures() {
 const firstName = faker.name.firstName();
 const lastName = faker.name.lastName();
 return {
   id: uuid4(),
   first_name: firstName,
   last_name: lastName,
   name: firstName + " " + lastName,
   post_count: Math.floor(Math.random() * 10),
   email: `${firstName}@yopmail.com`,
   bio: faker.lorem.sentence(20),
   username: firstName + lastName,
   avatar: null,
   created: faker.date.recent(),
   updated: faker.date.recent(),
 };
}
...

Faker 提供了许多模块和返回数据的函数。在上一个代码块中,我们使用 faker.name 生成随机姓名,使用 faker.lorem 生成随机 lorem 文本,以及使用 faker.date 生成最近日期。userFixtures 返回的对象现在与 Django API 返回的用户对象结构最接近,这正是我们想要的。

在深入组件测试之前,让我们确保我们的测试环境已经很好地配置。

运行第一个测试

当创建一个 React 应用程序时,App.js 文件附带一个名为 App.test.js 的测试文件,你可以在这里看到:

src/App.test.js

import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
 render(<App />);
 const linkElement = screen.getByText(/learn react/i);
 expect(linkElement).toBeInTheDocument();
});

让我来解释一下代码。在这里,我们正在从 RTL 中导入 renderscreen 方法。这些模块将被用来渲染一个组件,并通过提供选择 DOM 元素的方法来简化与组件的交互。

接下来,我们有 test 方法。它只是一个 Jest 关键字,用于编写测试。它接受两个参数:一个描述测试的字符串,以及一个回调函数,你在其中编写测试逻辑。在回调函数内部,首先渲染 App 组件。然后,使用 learn react 文本从屏幕中检索 linkElement。一旦检索到,我们就可以检查 linkElement 是否存在于渲染的文档中。

让我们使用以下命令运行这个测试:

yarn test

你应该在终端中得到类似的输出。

图 11.1 – 运行 yarn test 命令

图 11.1 – 运行 yarn test 命令

测试失败了。但是为什么呢?你可以在前面的输出中看到一些原因。

图 11.2 – App.js 测试失败的原因

图 11.2 – App.js 测试失败的原因

我们项目中的 App 组件使用了 react-router-dom 组件,例如 Routes,这些组件反过来又使用了 useRoutes Hook。这个 Hook 利用路由组件提供的上下文,因此我们需要将其包裹在一个 Router 中,在这种情况下,是 BrowserRouter 组件。让我们纠正这个问题,同时更改我们从中检索链接元素的文本:

src/App.test.js

import { render, screen } from "@testing-library/react";
import App from "./App";
import { BrowserRouter } from "react-router-dom";
test("renders Welcome to Postagram text", () => {
 render(
   <BrowserRouter>
     <App />
   </BrowserRouter>
 );
 const textElement =
   screen.getByText(/Welcome to Postagram!/i);
 expect(textElement).toBeInTheDocument();
});

现在,再次运行测试,一切应该都能正常工作:

图 11.3 – 通过测试

图 11.3 – 通过测试

但我们仍然有问题。React 应用程序中的许多组件使用了来自 react-router-dom 库的 Hooks。这意味着对于每个测试,我们都需要在 BrowserRouter 中包裹组件。遵循 DRY 原则,让我们重写 RTL 中的渲染方法,使其能够自动将我们的组件包裹在 BrowserRouter 中。

扩展 RTL 渲染方法

src/helpers 目录下,创建一个名为 test-utils.jsx 的文件。一旦文件创建完成,添加以下代码行:

src/helpers/test-utils.jsx

import React from "react";
import { render as rtlRender } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
function render(ui, { ...renderOptions } = {}) {
 const Wrapper = ({ children }) =>
   <BrowserRouter>{children}</BrowserRouter>;
 return rtlRender(ui, { wrapper: Wrapper, ...renderOptions
   });
}
export * from "@testing-library/react";
export { render };

在代码中,我们首先导入所需的工具。注意 rtlRender 的导入吗?这是为了避免命名冲突,因为我们也在编写一个渲染函数。接下来,我们创建一个名为 Wrapper 的函数,其中传递子组件的参数,然后将其包裹在一个 BrowserRouter 组件中。然后,我们返回一个包含 UI、wrapper 和其他指定渲染选项的渲染对象。

重要提示

RTL 提供的渲染方法除了包装器之外,还提供了其他渲染选项。您还可以传递一个容器、查询等更多内容。您可以在官方文档中查看更多渲染选项,网址为 testing-library.com/docs/react-testing-library/api/#render-options

现在,让我们在 App.test.js 中使用这个方法:

src/App.test.js

import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders Welcome to Postagram text", () => {
 render(<App />);
...
});

再次运行测试命令,一切应该都是绿色的。当测试环境准备就绪并设置好以便快速编写测试时,我们现在可以继续测试 React 项目的组件。

重要提示

在运行测试时,您可能会遇到来自 axios 包的错误。在撰写本书时,我们使用 axios 的 0.26.0 版本来避免在运行测试时出现错误。您也可以将 package.json 文件中的测试命令修改为以下内容:"test": "react-scripts test --transformIgnorePatterns "node_modules/(?!axios)/""。更多关于此问题的信息,请参阅 github.com/axios/axios/issues/5101

测试认证组件

在 React 中测试表单可能看起来很复杂,但使用 Jest 和 RTL 时却相当简单。我们将开始在 React 项目中编写测试,从认证组件开始。我会向您展示如何编写 登录 表单的测试,之后您应该能够编写注册表单的测试套件。

为了有更好的代码结构,在 src/components/authentication 目录中创建一个新的目录名为 __tests__。这个目录将包含 components/authentication 目录中组件的测试。在新建的目录中,创建一个名为 LoginForm.test.js 的文件,并添加以下代码:

src/components/authentication/tests/LoginForm.test.js

import { render, screen } from "../../../helpers/test-utils";
import userEvent from "@testing-library/user-event";
import LoginForm from "../LoginForm";
import { faker } from "@faker-js/faker";
import userFixtures from "../../../helpers/fixtures/user";
const userData = userFixtures();
test("renders Login form", async () => {
...
});

在前面的代码中,我们添加了编写测试所需的导入,并定义了测试函数的结构。我们将首先渲染 LoginForm 组件,并设置用户对象以使用 userEvent 方法触发用户行为事件:method:src/components/authentication/__tests__/LoginForm.test.js

...
test("renders Login form", async () => {
 const user = userEvent.setup();
 render(<LoginForm />);
...

重要提示

userEventfireEvent 都是用于在测试环境中模拟网站用户交互的方法。它们可以用来测试当用户执行某些操作(如点击按钮或填写表单)时网站的行为。

userEvent 是由 @testing-library/user-event 库提供的方法,旨在使测试网站的用户交互更容易。这是一个实用函数,它通过使用 @testing-library/react 库提供的 fireEvent 方法来模拟用户事件。userEvent 允许您指定要模拟的事件类型,例如点击或按键,并且它会自动为您派发适当的事件。

fireEvent 是由 @testing-library/react 库提供的,可以用来向 DOM 元素派发事件。它允许你指定你想要派发的事件类型,以及你想要包含的任何附加事件数据。fireEvent 是一个比 userEvent 更低级的方法,它需要你手动指定你想要派发的事件的详细信息。

之后,我们可以开始测试表单和输入是否已渲染到文档中:

src/components/authentication/tests/LoginForm.test.js

test("renders Login form", async () => {
...
 const loginForm = screen.getByTestId("login-form");
 expect(loginForm).toBeInTheDocument();
const usernameField = screen.getByTestId("username-field");
expect(usernameField).toBeInTheDocument();
const passwordField = screen.getByTestId("password-field");
expect(passwordField).toBeInTheDocument();
...

然后,我们可以确保输入可以接收文本和值,因为我们已经选择了用户名和密码字段:

src/components/authentication/tests/LoginForm.test.js

test("renders Login form", async () => {
...
 const password = faker.lorem.slug(2);
 await user.type(usernameField, userData.username);
 await user.type(passwordField, password);
 expect(usernameField.value).toBe(userData.username);
 expect(passwordField.value).toBe(password);
});

如果你再次运行测试命令,它将失败。这是正常的,因为在这里我们正在使用 getByTestId 方法检索元素。RTL 在渲染的 DOM 中查找具有 data-testid 属性且其值传递给 screen.getByTestId 函数的元素。我们需要将此属性添加到我们想要选择和测试的元素上。

要做到这一点,在 src/components/authentication/LoginForm.js 中添加以下 data-testid 属性:

src/components/authentication/LoginForm.js

function LoginForm() {
...
 return (
   <Form
     id="registration-form"
     className="border p-4 rounded"
     noValidate
     validated={validated}
     onSubmit={handleSubmit}
     data-testid="login-form"
   >
...
       <Form.Label>Username</Form.Label>
       <Form.Control
         value={form.username}
         data-testid="username-field"
...
     <Form.Group className="mb-3">
       <Form.Label>Password</Form.Label>
       <Form.Control
         value={form.password}
         data-testid="password-field"
...

完成后,重新运行测试命令。一切应该都会正常工作。

下一步是为注册表单组件编写测试。它将与登录表单组件上的测试类似,所以你可以处理这个小练习。你可以在github.com/PacktPublishing/Full-stack-Django-and-React/blob/main/social-media-react/src/components/authentication/__tests__/RegistrationForm.test.js找到解决方案。

重要提示

JavaScript 也具有测试文件的默认命名约定。JavaScript 项目的测试文件命名约定如下:

  • <``TestFileName>.test.js

  • <``TestFileName>.spec.js

现在我们已经对 React 中的测试有了坚实的介绍,让我们继续编写帖子组件的测试。

测试帖子组件

创建、读取、更新和删除帖子的功能是 Postagram 应用程序的核心功能,因此确保它们按预期工作非常重要。让我们从对 Post 组件的简单测试开始。

模拟 localStorage 对象

在编写Post组件的测试之前,理解Post组件的工作方式非常重要。基本上,它接受一个名为post的属性,并调用localStorage来检索有关用户的信息。不幸的是,localStorage不能被 Jest 模拟。有许多解决方案可以让你的测试与localStorage一起工作,并且使它简单且减少样板代码,我们将使用jest-localstorage-mockJavaScript 包。该包可以与 Jest 一起使用来运行依赖于localStorage的前端测试。要添加该包,请将以下行添加到文件中:

yarn add --dev jest-localstorage-mock

一旦安装了包,我们需要做一些配置。在src/setupTests.js文件中,添加以下行以加载jest-localstorage-mock包:

src/setupTests.js

...
require('jest-localstorage-mock');

之后,在package.json文件中覆盖默认的 Jest 配置:

package.json

...
{
  "jest": {
    "resetMocks": false
  }
}
...

配置就绪后,我们可以添加一个生成帖子固定数据的函数。

编写帖子固定数据

src/helpers/fixtures目录中,创建一个名为post.js的新文件。该文件将包含一个从帖子对象返回假数据的函数。

我们将开始编写此文件中的代码,添加导入并定义将返回生成帖子对象的postFixtures函数:

src/helpers/fixtures/post.js

import { faker } from "@faker-js/faker";
import { v4 as uuid4 } from "uuid";
import userFixtures from "./user";
function postFixtures(isLiked = true, isEdited = false, user = undefined) {
...
}
export default postFixtures;

让我们添加postFixtures函数的主体:

src/helpers/fixtures/post.js

...
function postFixtures(isLiked = true, isEdited = false, user = undefined) {
 return {
   id: uuid4(),
   author: user || userFixtures(),
   body: faker.lorem.sentence(20),
   edited: isEdited,
   liked: isLiked,
   likes_count: Math.floor(Math.random() * 10),
   comments_count: Math.floor(Math.random() * 10),
   created: faker.date.recent(),
   updated: faker.date.recent(),
 };
}

在这里,我们传递一个生成的userFixtures或一个已定义的用户对象。如果我们想确保帖子的作者与在localStorage中注册的用户相同,这是很重要的。

在编写帖子固定数据后,我们可以为Post组件编写测试套件。

编写Post组件的测试

要在src/components/posts目录中编写测试套件,创建一个名为__tests__的新文件夹。在新建的文件夹中,添加一个名为Post.test.js的新文件。在文件中,添加导入,创建所需的数据,并使用setUserData函数在本地存储中设置userFixtures函数返回的用户数据:

src/components/posts/tests/Post.test.js

import { render, screen } from "../../../helpers/test-utils";
import Post from "../Post";
import { setUserData } from "../../../hooks/user.actions";
import userFixtures from "../../../helpers/fixtures/user";
import postFixtures from "../../../helpers/fixtures/post";
const userData = userFixtures();
const postData = postFixtures(true, false, userData);
beforeEach(() => {
 // to fully reset the state between __tests__, clear the
 // storage
 localStorage.clear();
 // and reset all mocks
 jest.clearAllMocks();
 setUserData({
   user: userData,
   access: null,
   refresh: null,
 });
});

beforeEach方法是一个 Jest 方法,它在每个测试之前运行。它接受一个回调函数作为参数,其中可以执行应该在测试之前运行的代码行。在这里,我们首先清除本地存储以避免内存泄漏(使用localStorage.clear),最后,我们将从userFixtures函数检索的用户数据设置在本地存储中。

重要提示

当程序在堆中创建内存并忘记删除它时,会发生内存泄漏。在最坏的情况下,如果分配了太多内存且未正确使用,这可能会降低计算机的性能。

现在让我们编写Post组件的测试:

src/components/posts/tests/Post.test.js

...
test("render Post component", () => {
 render(<Post post={postData} />);
 const postElement = screen.getByTestId("post-test");
 expect(postElement).toBeInTheDocument();
});

如果你运行测试命令,它将失败。这是正常的,因为在Post组件的 JSX 中没有设置值为post-testdata-testid属性。让我们通过在Post组件中添加data-testid属性来修复这个问题:

src/components/posts/Post.jsx

...
function Post(props) {
...
 return (
   <>
     <Card className="rounded-3 my-4"
       data-testid="post-test">
...
   </>
 );
}
export default Post;

再次运行测试命令,一切应该都是绿色的。让我们继续编写CreatePost组件的测试。

测试 CreatePost 组件

src/components/posts/__tests__目录下,创建一个名为CreatePost.test.js的新文件。我们将从必要的导入和test函数的定义开始:

src/components/posts/tests/CreatePost.test.js

import { render, screen, fireEvent } from "../../../helpers/test-utils";
import userEvent from "@testing-library/user-event";
import CreatePost from "../CreatePost";
import { faker } from "@faker-js/faker";
test("Renders CreatePost component", async () => {
...
});

你可以注意到在回调函数之前引入了async关键字。为了创建帖子,用户在文本输入框中进行输入操作,最后点击按钮提交帖子。这些操作是异步的。我们将使用来模拟用户交互的函数,如fireEvent,应该在异步作用域中使用。

在编写测试逻辑之前,让我们回顾一下CreatePost组件是如何工作的:

  1. 用户点击输入框以添加新的帖子。

  2. 显示一个包含表单的模态框,用户可以输入帖子的文本。同时,提交按钮处于禁用状态。

  3. 当字段中有足够的文本时,提交按钮被启用,用户可以点击发送帖子。

我们必须确保在编写测试时尊重这个逻辑。现在,让我们开始编写测试。

首先,我们渲染显示创建帖子模态框的表单:

src/components/posts/tests/CreatePost.test.js

test("Renders CreatePost component", async () => {
 const user = userEvent.setup();
 render(<CreatePost />);
 const showModalForm =
   screen.getByTestId("show-modal-form");
 expect(showModalForm).toBeInTheDocument();
});

我们现在可以使用fireEvent.clickshowModalForm上模拟点击事件来显示创建帖子的表单:

src/components/posts/tests/CreatePost.test.js

...
 // Clicking to show the modal
 fireEvent.click(showModalForm);
 const createFormElement =
   screen.getByTestId("create-post-form");
 expect(createFormElement).toBeInTheDocument();
...

我们确保正文字段被渲染,提交按钮被禁用:

src/components/posts/tests/CreatePost.test.js

...
 const postBodyField =
   screen.getByTestId("post-body-field");
 expect(postBodyField).toBeInTheDocument();
 const submitButton =
   screen.getByTestId("create-post-submit");
 expect(submitButton).toBeInTheDocument();
 expect(submitButton.disabled).toBeTruthy();
 ...

之后,我们可以在正文字段中输入一些文本,测试输入的文本是否符合预期,并确保之后按钮处于启用状态:

src/components/posts/tests/CreatePost.test.js

 ...
 const postBody = faker.lorem.sentence(10);
 await user.type(postBodyField, postBody);
 // Checking if field has the text and button is not
 // disabled
 expect(postBodyField.value).toBe(postBody);
 expect(submitButton.disabled).toBeFalsy();
});

太好了!我们有一个可靠的测试套件,现在我们可以向CreatePost组件添加data-testid属性以使测试通过:

src/components/posts/CreatePost.jsx

function CreatePost() {
...
 return (
   <>
     <Form.Group className="my-3 w-75">
       <Form.Control
         className="py-2 rounded-pill border-primary
                    text-primary"
         data-testid="show-modal-form"
...
       <Modal.Body className="border-0">
         <Form
           noValidate
           validated={validated}
           onSubmit={handleSubmit}
           data-testid="create-post-form"
         >
           <Form.Group className="mb-3">
             <Form.Control
               name="body"
               data-testid="post-body-field"
...
       </Modal.Body>
       <Modal.Footer>
         <Button
           variant="primary"
           onClick={handleSubmit}
           disabled={!form.body}
           data-testid="create-post-submit"
...
   </>
 );
}

再次运行测试命令,一切应该正常工作。下一步是编写UpdatePost组件的单元测试。

测试 UpdatePost 组件

src/components/posts/__tests__目录下,创建一个名为UpdatePost.test.js的新文件。让我们从必要的导入和test函数的定义开始:

src/components/posts/tests/UpdatePost.test.js

import { render, screen, fireEvent } from "../../../helpers/test-utils";
import userEvent from "@testing-library/user-event";
import UpdatePost from "../UpdatePost";
import userFixtures from "../../../helpers/fixtures/user";
import postFixtures from "../../../helpers/fixtures/post";
import { faker } from "@faker-js/faker";
const userData = userFixtures();
const postData = postFixtures(true, false, userData);
test("Render UpdatePost component", async () => {
...
});

在编写测试逻辑之前,让我们回顾一下从用户的角度来看UpdatePost组件是如何工作的:

  1. 用户点击下拉菜单项来修改帖子。

  2. 显示一个包含表单的模态框,用户可以修改帖子的文本。

  3. 修改后,用户可以使用更新后的帖子提交表单。

我们必须确保在编写测试时尊重这种逻辑。

因此,首先,我们渲染显示更新帖子表单的表单模态:

src/components/posts/tests/UpdatePost.test.js

test("Render UpdatePost component", async () => {
 const user = userEvent.setup();
 render(<UpdatePost post={postData} />);
 const showModalForm =
   screen.getByTestId("show-modal-form");
 expect(showModalForm).toBeInTheDocument();
...

然后,我们希望触发一个点击事件来显示用于更新帖子的表单模态:

src/components/posts/tests/UpdatePost.test.js

...
 fireEvent.click(showModalForm);
 const updateFormElement =
   screen.getByTestId("update-post-form");
 expect(updateFormElement).toBeInTheDocument();
...

然后,我们选择帖子正文字段和提交按钮,以确保它们被渲染:

src/components/posts/tests/UpdatePost.test.js

...
 const postBodyField =
   screen.getByTestId("post-body-field");
 expect(postBodyField).toBeInTheDocument();
 const submitButton =
   screen.getByTestId("update-post-submit");
 expect(submitButton).toBeInTheDocument();
...

之后,我们现在可以在帖子正文字段中触发一个输入事件,并确保用户提交了正确的数据:

src/components/posts/tests/UpdatePost.test.js

...
 const postBody = faker.lorem.sentence(10);
 await user.type(postBodyField, postBody);
 // Checking if field has the text and button is not
 // disabled
 expect(postBodyField.value).toBe(postData.body +
   postBody);
 expect(submitButton.disabled).toBeFalsy();
});

下一步是将data-testid属性添加到UpdatePost组件中的帖子表单、帖子正文输入和提交按钮,以便测试通过:

src/components/posts/UpdatePost.jsx

...
function UpdatePost(props) {
...
 return (
   <>
     <Dropdown.Item data-testid="show-modal-form"
       onClick={handleShow}>
...
       <Modal.Body className="border-0">
         <Form
          noValidate
          validated={validated}
          onSubmit={handleSubmit}
    data-testid="update-post-form"
   >
           <Form.Group className="mb-3">
             <Form.Control
               name="body"
               value={form.body}
               data-testid="post-body-field"
...
       </Modal.Body>
       <Modal.Footer>
         <Button
           data-testid="update-post-submit"
...

再次运行测试命令,一切应该都会正常工作。

通过对 Jest 和 RTL 的复杂测试的介绍,您可以轻松编写评论组件的测试。您可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/main/social-media-react/src/components/comments/__tests__找到这些测试的解决方案。祝您好运!

在下一节中,我们将了解快照测试是什么。

快照测试

当您想确保 UI 不会意外改变时,快照测试是一个非常有用的工具。一个快照测试案例遵循以下步骤:

  • 它渲染 UI 组件。

  • 然后,它将捕获一个快照并将其与存储在测试文件旁边的参考快照文件进行比较。

  • 如果两个状态相同,快照测试就成功了。否则,您将得到错误,并需要决定是否需要更新快照测试或修复您的组件。

快照测试非常有助于防止 UI 回归并确保应用程序遵守您的开发团队的代码质量和价值观。

然而,快照测试存在一个小问题。快照测试与动态组件配合得不是很好。例如,Post组件使用timeago来显示可读时间。这意味着在时间t的该组件的快照将不同于在时间t + 1的同一组件的快照。然而,在 React 应用程序中也有一些静态组件,例如LoginFormRegistrationFormProfileDetailsProfileCardCreatePost等等。

为了简化,我们将为ProfileCard组件编写一个快照测试,这些组件简单直接,并且可以轻松复制。

src/components/profile目录下,创建一个名为__tests__的新目录。然后,创建一个名为ProfileCard.test.js的新文件。对于快照测试,我们不希望数据发生变化,因此我们将使用静态用户固定值,因为使用userFixtures生成固定值会在每次运行快照测试时创建随机数据。在新建的文件中,让我们添加创建快照测试所需的导入,并定义一个名为userData的固定值对象:

src/components/profile/tests/ProfileCard.test.js

import { render, screen } from "../../../helpers/test-utils";
import TestRenderer from "react-test-renderer";
import ProfileCard from "../ProfileCard";
import { BrowserRouter } from "react-router-dom";
const userData = {
 id: "0590cd67-eacd-4299-8413-605bd547ea17",
 first_name: "Mossie",
 last_name: "Murphy",
 name: "Mossie Murphy",
 post_count: 3,
 email: "Mossie@yopmail.com",
 bio: "Omnis necessitatibus facere vel in est provident
       sunt tempora earum accusantium debitis vel est
       architecto minima quis sint et asperiores.",
 username: "MossieMurphy",
 avatar: null,
 created: "2022-08-19T17:31:03.310Z",
 updated: "2022-08-20T07:38:47.631Z",
};

在添加了所需的导入并编写了userData固定值之后,我们现在可以编写测试函数:

src/components/profile/tests/ProfileCard.test.js

...
test("Profile Card snapshot", () => {
 const profileCardDomTree = TestRenderer.create(
   <BrowserRouter>
     <ProfileCard user={userData} />
   </BrowserRouter>
 ).toJSON();
 expect(profileCardDomTree).toMatchSnapshot();
});

如果你运行测试命令,你会注意到在__tests__目录下创建了一个快照目录:

图 11.4 – 创建了快照目录

图 11.4 – 创建了快照目录

如果你检查ProfileCard.test.js.snap的内容,它基本上是ProfileCard组件的渲染代码。每次快照测试的测试函数运行时,都会比较这个文件的内容。

现在我们已经涵盖了 React 应用程序的基本单元测试,我们主要完成了添加功能的工作。我们的全栈应用程序现在已准备好投入生产!太好了,但不要过早庆祝。我们仍然需要在安全、质量和性能方面为我们的应用程序做好生产准备,这正是本书第三部分将要做的。

摘要

在本章中,你学习了前端单元测试。我们发现了为什么在前端应用程序中编写单元测试很重要,以及确切要测试什么。我们还为 Postagram 应用程序中的组件编写了测试,看到了如何扩展测试工具模块和方法,如何编写测试的生成固定值,以及如何通过触发用户事件使测试更接近用户交互。我们还对快照测试做了一些介绍。

本书第三部分的下一章将重点介绍使用 AWS 服务、GitHub 和 GitHub Actions 在云上部署后端和前端。最后,我们将看到如何从性能、安全性和质量方面改进全栈应用程序。

问题

  1. RTL 的渲染方法是什么?

  2. Jest 是什么?

  3. data-tested属性的作用是什么?

  4. 快照测试的缺点是什么?

  5. 用于在 React 测试套件中触发用户事件的模块有哪些?

第三部分:在 AWS 上部署 Django 和 React

部署是软件开发中最后的重要步骤之一。您的应用程序在本地运行且一切正常。但您如何将代码部署到公共服务器上?您如何托管前端?您如何修改代码并使部署和测试自动化?在本书的这一部分,我们将探讨在 AWS EC2 上部署 Django 应用和在 AWS S3 上部署 React 应用时涉及的 CI/CD、GitHub、Docker 以及最佳部署实践等主题。我们还将讨论安全和性能问题。

本节包含以下章节:

  • 第十二章部署基础 – Git、GitHub 和 AWS

  • 第十三章将 Django 项目容器化

  • 第十四章在 AWS 上自动化部署

  • 第十五章在 AWS 上部署我们的 React 应用

  • 第十六章性能、优化和安全

第十二章:部署基础 – Git、GitHub 和 AWS

在您的机器上开发一个具有功能后端和良好、灵活前端的程序是件好事。然而,如果您想公开使用您的应用程序,您需要将应用程序部署到生产环境。从本章到最后,您将学习如何为部署准备我们构建的应用程序,在 亚马逊网络服务AWS)上部署后端,在 Vercel 上部署前端,并最终进行一些安全和性能优化。

在本章中,我们将学习部署基础,如术语和概念,以便在进一步学习之前理解。我们将学习以下主题:

  • 软件部署基础

  • 网络应用程序部署的工具和方法

  • 网络应用程序部署平台

技术要求

对于本章,您需要在您的机器上安装 Git。如果您使用的是 Linux 或 macOS,它将默认安装。您可以在终端中使用以下命令检查其存在性:

git –version

否则,您可以自由地下载正确的版本,请访问 git-scm.com/downloads

安装完成后,如果尚未完成,让我们配置 Git。在终端中,输入以下配置命令以设置用户名(通常是您的 GitHub 账户上的用户名)和电子邮件地址(通常是您的 GitHub 账户上的电子邮件地址):

git config --global user.name "username"
git config --global user.email "email@address.com"

您还需要一个活跃的 GitHub 账户。您可以在官方网站 github.com/ 上注册。由于我们还将应用程序部署到远程 AWS 服务器,您需要一个可以在 portal.aws.amazon.com/billing/signup 创建的 AWS 账户。如果您没有 AWS 账户,您仍然可以使用您在线的任何 虚拟专用服务器VPS)或 虚拟专用云VPC)。然而,本章还将记录如何使用 AWS 创建 VPC 实例以及如何上传代码和提供 Django API。

软件部署基础

软件部署涉及使软件系统可供消费者使用的所有活动。术语 软件部署 也通常描述为应用程序部署。遵循最佳软件部署实践将确保所有部署的应用程序都能平稳运行并按预期工作。

软件部署有几种好处,例如:

  • 节省时间:良好的软件部署流程可以配置为只需几分钟即可完成。这节省了编译和分发给用户的时间。

  • 增强安全性:以结构化的方式部署您的应用程序,而不是手动部署或为单个用户部署,这意味着您确保了应用程序的安全性,而不仅仅是每个用户设备上的应用程序安全性。

  • 更好的监控:在生产服务器上部署应用程序有助于提供更多关于用户端工作情况的控制和数据。

软件部署定义后,我们将更深入地探讨用于 Web 应用程序部署的工具和方法。

Web 应用程序部署的工具和方法

部署 Web 应用程序到生产环境在近年来经历了巨大的演变。从手动部署到自动化部署技术,Web 应用程序部署已经进步,使过程更加安全、顺畅,尽可能快。有许多 Web 应用程序部署工具,但在这本书中,我们将专注于自动化工具,并在代码的远程仓库上推送时配置 Django 项目和 React 项目以进行自动化部署。

但代码首先会被推送到哪里呢?让我们开始描述和学习如何使用工具来部署我们的全栈应用程序,从 Git 和 GitHub 开始。

使用 Git 和 GitHub

Git 是一个流行的源代码版本控制和协作工具。它不仅帮助用户跟踪代码的更改,还允许开发者通过小型或大型代码库进行工作,使协作更加容易。在以下小节中,我们将在后端项目中初始化 Git 仓库,提交更改,然后将更改推送到 GitHub 上的远程仓库。

创建 Git 仓库

在你创建 Django 项目的目录中打开一个新的终端,并输入以下命令:

git init

此命令将在当前目录中创建一个空的.git/目录:这是一个 Git 仓库。此仓库跟踪项目中文件的所有更改,帮助构建更改的历史,包括更改的文件、更改人员的姓名以及更多信息。

初始化后,我们需要在项目中忽略一些文件。我们谈论的是像.pycache.env和虚拟环境目录这样的文件。毕竟,我们不希望重要的信息,如秘密环境变量,在项目中可用,或者无用的缓存文件出现在更改中。

在 Django API 的目录内创建一个名为.gitignore的新文件。此文件告诉 Git 在跟踪更改时应忽略哪些文件和目录:

.gitignore

__pycache__
venv
env
.env

上述代码中的这些文件和目录将被忽略。接下来,我们将目录中的更改添加到暂存区。暂存区允许你在提交到项目历史之前将相关更改分组。由于我们已经成功添加了.gitignore文件,我们可以自由运行git add命令:

git add .

命令末尾的点(.)告诉 Git 只在该当前目录中查找已更改的文件。要查看要提交到 Git 历史的更改,请运行以下命令:

git status

git status命令用于显示工作目录和暂存区的状态。使用该命令,你可以看到被跟踪或未被跟踪的更改。以下图示展示了你应该得到的输出示例:

图 12.1 – 运行 git status 命令

图 12.1 – 运行 git status 命令

我们现在可以运行git commit命令。提交是将源代码的最新更改写入版本控制系统历史记录的操作。在我们的例子中,使用git commit命令将保存更改到本地仓库:

git commit

上述命令将提示您在终端或应用程序中的文本编辑器中输入消息。无论哪种方式,您都需要输入一条消息。输入有意义的消息很重要,因为这条消息将显示在源代码更改的历史记录中。如果您想输入以下行:

Initialize git in API project

保存消息后,您可以使用git log命令检查 Git 历史记录:

git log

您将看到以下类似图示:

图 12.2 – 编写提交信息

图 12.2 – 编写提交信息

重要提示

编写有意义的提交信息很重要,尤其是在团队或协作环境中。您可以在www.conventionalcommits.org/en/v1.0.0/了解更多关于提交信息的内容。

项目仓库已在本地初始化;然而,我们希望代码在 GitHub 上。下一节将向您展示如何上传您的代码到 GitHub。

上传代码到 GitHub

GitHub 是一个用于协作和版本控制的代码托管平台。它帮助世界各地的开发者共同工作,实际上是大多数流行开源项目的代码托管平台。

在您的 GitHub 账户仪表板上,在导航栏中,创建一个新的仓库:

图 12.3 – 在 GitHub 上创建仓库

图 12.3 – 在 GitHub 上创建仓库

完成后,您将被重定向到一个新页面,以输入有关仓库的基本信息,例如仓库名称和描述,说明仓库是公开的还是私有的,以及添加许可证或.gitignore文件。仓库名称是必需的,其他信息是可选的。

您现在可以创建仓库,您将看到类似以下页面:

图 12.4 – 创建的仓库

图 12.4 – 创建的仓库

我们有一个现有的仓库,我们希望将其推送到 GitHub 平台。让我们按照…或从命令行推送现有仓库的步骤进行。在您的后端项目目录中,打开一个新的终端,让我们输入 shell 命令:

git remote add origin your_repository_git_url

git remote命令允许您创建、查看和删除连接到互联网或另一个网络上的 Git 仓库。在上面的命令中,我们正在添加 GitHub 仓库的远程仓库 URL。让我们更改我们正在工作的分支名称:

git branch -M main

默认情况下,当在本地机器上使用 Git 创建仓库时,工作分支被称作 master。Git 中的分支是什么?

嗯,这只是主仓库的一个独立版本。这允许多个开发者共同工作在同一个项目上。例如,如果你与一个想要在帖子及评论中添加文件上传支持的后端开发者合作,开发者可以直接在主分支上创建一个新的分支(feature/images-post),而不是直接在主分支上工作。在这个分支上的工作完成后,feature/images-post 分支可以与主分支合并。

主分支创建后,我们现在可以将更改推送到 GitHub:

git push -u origin main

git push 命令用于将本地仓库的源代码更改上传到远程仓库。在你的情况下,该命令将把当前代码推送到你的 GitHub 仓库 URL。

在 GitHub 上重新加载仓库页面,你会看到类似以下内容:

图 12.5 – 代码推送到仓库

图 12.5 – 代码推送到仓库

哇!我们已经将代码上传到 GitHub。但这只是代码。如果你能在任何地方访问的远程服务器上运行它怎么办?

让我们谈谈网络应用程序部署的平台,并在 AWS 上部署 Django 后端。

网络应用程序部署平台

随着软件开发复杂性的增加和每年更多创新和数据处理密集型应用的演变或创建,出现了大量服务,允许团队轻松地将他们的产品部署到互联网上并进行扩展。这创造了一种新的服务类型:云计算:通过互联网按需交付 IT 资源,采用按使用付费的定价模式。

在这本书中,我们将部署 AWS 上的后端,主要是在一个 弹性计算云EC2)实例上,这只是一个 VPS 的花哨名称。实际上,AWS EC2 实例是亚马逊 EC2 中的一个虚拟服务器,用于运行网络应用程序。让我们先创建 AWS 服务器。

重要提示

以下步骤适用于任何 VPS,而不仅仅是 AWS VPS。如果你在 AWS 上无法创建 VPS,你可以查看其他解决方案,如 Linode、谷歌云平台GCP)、Azure 或 IBM。它们提供免费信用额度,你可以用来了解它们的服务。

创建 EC2 实例

按照以下步骤创建 EC2 实例:

  1. 确保你已经登录到你的 AWS 账户。在仪表板上,打开 EC2 控制台:

图 12.6 – 访问 EC2 控制台

图 12.6 – 访问 EC2 控制台

  1. 在 EC2 控制台中,启动一个新的实例:

图 12.7 – 创建 EC2 实例

图 12.7 – 创建 EC2 实例

你将看到一个页面,你需要配置实例。

  1. 输入实例名称:

图 12.8 – 命名 EC2 实例

图 12.8 – 命名 EC2 实例

  1. 下一步是选择操作系统。我们将使用Ubuntu Server 22.04 LTS作为亚马逊机器****镜像AMI):

图 12.9 – 在 EC2 实例上选择操作系统

图 12.9 – 在 EC2 实例上选择操作系统

我们在这里使用 Ubuntu,因为它具有安全性、多功能性和定期更新的政策。然而,您也可以自由使用您熟悉的任何其他 Linux 发行版。

  1. 最后,您需要设置实例类型并为Secure ShellSSH)登录创建一对密钥。之后,您可以启动实例:

图 12.10 – 启动实例

图 12.10 – 启动实例

  1. 稍等片刻,实例将被创建:

图 12.11 – 实例已创建

图 12.11 – 实例已创建

  1. 点击查看所有实例按钮,您将看到创建的 Postagram 实例。

  2. 点击实例名称旁边的复选框,然后点击连接按钮:

图 12.12 – 连接到 EC2 实例

图 12.12 – 连接到 EC2 实例

这将带您到一个页面,其中包含通过 SSH 连接所需的信息和步骤

图 12.13 – 通过 SSH 连接到 EC2 实例

图 12.13 – 通过 SSH 连接到 EC2 实例

  1. 在您的终端中,键入以下命令通过 SSH 连接:

    ssh -i path/to/your_keypair.pem ec2-user@ipaddress
    
  2. 一旦连接到服务器,我们将配置它以在机器上运行 Django 后端,并可以从互联网访问:

    sudo apt update
    
    sudo apt upgrade
    

前面的命令更新了 Ubuntu 软件包的apt索引,并升级了服务器上的所有软件包。

Django 项目将在机器的8000端口上运行,因此我们必须允许对此端口的连接。默认情况下,EC2 实例将只允许通过80端口进行 HTTP 请求的连接,22端口进行 SSH 连接,有时通过443端口进行Secure Sockets LayerSSL)连接。

您可以直接在创建的 EC2 实例的详情页面允许端口8000的连接,以访问页面底部选项卡列表中的安全设置组:

图 12.14 – 安全选项卡

图 12.14 – 安全选项卡

在安全组设置中,访问操作菜单并点击编辑入站****规则。您将可以访问一个页面,您可以在此添加一条新规则,如下所示:

  • 连接类型设置为自定义 TCP

  • 端口范围设置为8000

  • 源设置为0.0.0.0,表示所有请求应重定向到端口8000上的机器

  • 最后,添加一个默认描述,以免忘记我们添加此规则的原因

点击8000

图 12.15 – 添加新的安全规则

图 12.15 – 添加新的安全规则

服务器现在已准备好工作,我们可以现在运行 Django 后端应用程序。让我们在以下部分中查看下一步。

配置 Django 项目的服务器

Django 项目的源代码托管在 GitHub 上。直接使用 scp 从您的机器复制代码到远程机器是完全可能的,但让我们使用 Git,因为这将是我们工作流程中的重要命令。在远程实例的终端中,输入以下命令:

git clone your_repository_git_url

在我的情况下,我正在使用以下存储库进行此项目:

git clone https://github.com/PacktPublishing/Full-stack-Django-and-React.git –branch chap12

git clone 命令用于从互联网上的远程机器或另一个网络上的远程机器获取现有存储库的副本。–branch 标志用于指定您想要克隆的特定分支。

重要提示

由于我正在使用本书项目的存储库进行工作,当前的代码和操作都是在 chap12 分支上。在您的案例中,如果您正在使用自己的存储库,您可能不需要使用 –branch 标志。此外,根据 GitHub 存储库是私有还是公共,如果存储库是私有的,您才需要输入您的 GitHub 凭据。

git clone 命令将在新目录中克隆项目的所有内容。进入新创建的目录,让我们开始配置项目。我们将遵循在 第一章 中完成的步骤,即 创建 Django 项目,直到创建 Django 项目:

  1. 首先,使用以下命令创建一个虚拟环境:

    python3 -m venv venv
    
  2. 使用以下命令激活虚拟环境:

    source venv/bin/activate
    
  3. 让我们安装 requirements.txt 文件中的包:

    pip install -r requirements.txt
    

太好了!项目已准备就绪,但我们需要配置一个 Postgres 服务器,以便 Django 项目可以运行。

Postgres 配置和部署

在本书的 创建 Django 项目第一章 中,我们通过直接安装可执行文件或构建源代码来配置 Postgres。在 EC2 实例上,我们将直接使用 apt 工具安装 Postgres 服务器。您可以根据以下步骤在 EC2 机器上安装 Postgres 服务器:

  1. 输入以下命令安装 Postgres 服务器:

    sudo apt install postgresql-14
    
  2. 让我们连接到 psql 控制台并创建一个数据库:

    sudo su postgres 
    
    psql
    
  3. 太好了!让我们在 CoreRoot/settings.py 文件中的 DATABASES 设置上创建具有相同信息的数据库:

CoreRoot/settings.py

...
DATABASES = {
    'default': {
        'ENGINE':
          'django.db.backends.postgresql_psycopg2',
        'NAME': coredb,
        'USER': 'core',
        'PASSWORD': 'wCh29&HE&T83',
        'HOST': 'localhost',
        'PORT': '5342',
    }
}
...
  1. psql 控制台上输入以下命令以创建 coredb 数据库:

    CREATE DATABASE coredb;
    
  2. 要连接到数据库,我们需要一个带有密码的用户。执行以下命令:

    CREATE USER core WITH PASSWORD 'wCh29&HE&T83';
    
  3. 下一步是授予新用户对数据库的访问权限:

    GRANT ALL PRIVILEGES ON DATABASE coredb TO core;
    
  4. 我们几乎完成了。我们还需要确保此用户可以创建数据库。当我们运行测试时,这将非常有用。要运行测试,Django 将配置一个完整的环境,但也会使用数据库:

    GRANT CREATE PRIVILEGE TO core;
    

我们已经完成了数据库的创建。接下来,让我们将此数据库连接到我们的 Django 项目:

  1. 在项目目录中运行migrate命令:

    python manage.py migrate.
    
  2. migrate命令应该通过,现在我们可以通过运行以下命令来启动 Django 服务器:

    python manage.py runserver 0.0.0.0:8000
    
  3. Django 服务器运行后,在你的网页浏览器中访问http://public_ip:8000以访问你的 Django 项目。你将看到一个类似于以下图所示的页面:

图 12.16 – 不允许的主机错误

图 12.16 – 不允许的主机错误

这实际上是一个错误。这是由于ALLOWED_HOSTS设置为空导致的。这是 Django 为了防止诸如 HTTP 主机头攻击等安全漏洞而实现的。ALLOWED_HOSTS设置包含 Django 可以服务的主机名或域名列表:

CoreRoot/settings.py

...
ALLOWED_HOSTS = []
...
  1. 由于我们是从终端运行项目,让我们直接在服务器上修改设置文件:

    vim CoreRoot/settings.py
    

或者,你可以使用emacsnano命令。由你决定。以下行告诉 Django 接受来自任何主机名的请求:

CoreRoot/settings.py

...
ALLOWED_HOSTS = ["*"]
...
  1. 保存文件并再次启动服务器:

    python manage.py runserver 0.0.0.0:8000
    
  2. 然后,再次,在你的网页浏览器中访问http://public_ip:8000。你会看到以下内容:

图 12.17 – DisallowedHost 问题已解决

图 12.17 – DisallowedHost 问题已解决

太好了!项目在互联网上运行良好,你甚至可以使用 Postman 或 Insomnia 等 API 客户端来玩转 API。恭喜!你已经在 AWS EC2 机器上成功部署了你的 Django 应用程序。

然而,我们遇到了很多问题(我们可以在互联网上直接访问调试信息,就像图 12.17所示),我们做出了一些危险的决定,例如不通过 HTTPS 提供服务 API 或在整个部署过程中没有正确设置允许的主机。让我们在下一节中探讨这些问题。

在 EC2 上部署时犯的错误

我们已经在 AWS 上成功部署了 Django 后端。然而,我决定忽略一些重要的和最佳实践部署,以便我们可以尽快运行 Django 服务器。让我们纠正这一点。让我们从 Django 可以显示给我们的错误开始。在远程服务器的项目终端中,运行以下命令:

python manage.py check –deploy

这是前面命令的输出:

System check identified some issues:
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' indicating that it was generated automatically by Django. Please generate a long and random SECRET_KEY, otherwise many of Django's security-critical features will be vulnerable to attack.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.
?: (security.W018) You should not have DEBUG set to True in deployment.
System check identified 6 issues (0 silenced).

这有很多东西。由于我们正在构建一个 API,让我们关注与我们的 API 相关的安全问题:

  • SECRET_KEY:这是 Django 中的一个重要设置。它用于所有会话、加密签名甚至PasswordReset令牌。为SECRET_KEY设置一个已存在的值可能会导致危险的安全问题,如权限提升和远程代码执行。

  • DEBUG,设置为True。这就是我们能够看到DisallowedHost错误的原因。想象一下,一个攻击者遍历你的 API,导致500错误,然后能够读取一切。这将非常糟糕。

这些大多是 Django 检测到的错误。在最后一节“Postgres 配置和部署”中,我们通过让 Django 允许 Host 头中出现的任何主机名来解决 DisallowedHost 错误。嗯,这实际上是不好的,因为它可能导致 HTTP 主机头攻击,这是一种用于网络缓存投毒、邮件链接投毒以及修改敏感操作(如密码重置)的技术。

重要注意事项

你可以在 www.invicti.com/web-vulnerability-scanner/vulnerabilities/http-header-injection/ 上了解更多关于 HTTP 主机头攻击的信息。

还有一些与开发者体验相关的问题。确实,我们已经看到了如何使用 Git 和 GitHub 在线托管源代码,将其克隆到远程服务器上,然后配置它以进行部署。你可以重复同样的过程,对吧?但是,当你每天需要多次更新功能或修复代码时会发生什么呢?这可能会很快变得令人疲惫,因此我们需要在我们的 EC2 服务器上实现自动化部署的解决方案。

此外,我们还有 Postgres,最后,Django 项目是独立运行的。有时,可能会有需要将另一个服务添加到机器上的时刻。这可以手动完成,但会引发一个问题:生产环境开始与开发环境不同。

确保开发环境和生产环境尽可能相似是一个重要的习惯;这可以使错误的重现更容易,同时也有助于功能的开发可预测。

所有这些问题都将在下一章中解决。你将了解环境变量、Docker、NGINX 以及 GitHub Actions 中的持续集成/持续部署(CI/CD)概念。

摘要

在本章中,我们已经在 EC2 实例上成功部署了一个 Django 应用程序。在部署 Django 应用程序之前,我们使用 Git 在本地机器上创建了一个仓库,然后在 GitHub 上创建了一个远程仓库并将更改推送到线上。

我们还学习了如何通过安装如 Postgres 服务器等基本和有趣的工具手动配置服务器以进行部署。我们还探讨了在部署应用程序时出现的错误以及我们将在以下章节中如何解决这些错误。

这些错误将在下一章中解决,但首先,我们将在下一章中学习更多关于环境变量和 Docker 的知识。

问题

  1. Git 分支的用途是什么?

  2. Git 和 GitHub 之间有什么区别?

  3. 什么是 HTTP 主机头攻击?

  4. Django 中的 SECRET_KEY 有什么用途?

第十三章:将 Django 项目 Docker 化

在上一章中,我们学习了更多关于软件部署的知识,并在 AWS 服务器上部署了 Django 应用程序。然而,我们遇到了一些问题,比如项目部署准备不足、违反了一些安全问题和部署及开发配置。

在本章中,我们将学习如何在 Django 后端使用 Docker 并配置环境变量。我们还将使用 Docker 在名为 NGINX 的 Web 服务器上配置数据库。以下是本章的主要部分:

  • 什么是 Docker?

  • 将 Django 应用程序 Docker 化

  • 使用 Docker Compose 对多个容器进行操作

  • 在 Django 中配置环境变量

  • 编写 NGINX 配置

技术要求

对于本章,您需要在您的机器上安装 Docker 和 Docker Compose。Docker 官方文档对任何操作系统平台的安装过程都有详细的说明。您可以在docs.docker.com/engine/install/查看。

本章中编写的代码也可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap13找到。

什么是 Docker?

在定义 Docker 之前,我们必须了解容器是什么以及它在当今技术生态系统中的重要性。为了简单起见,容器是一个标准的软件单元,它将软件及其所有必需的依赖项打包起来,以便软件或应用程序可以从一台机器快速且可靠地运行到另一台机器,无论环境或操作系统如何。

来自 2013 年 PyCon 谈话的所罗门·海克斯的一个有趣的定义是:容器是“你可以从服务器那里发送到服务器那里的自包含的软件单元,从你的笔记本电脑到 EC2 到裸机巨型服务器,它将以相同的方式运行,因为它在进程级别上是隔离的,并且拥有自己的 文件系统。”

重要提示

容器化与虚拟化不同。虚拟化使团队能够在同一硬件上运行多个操作系统,而容器化允许团队在单个硬件上使用自己的镜像和依赖项,在相同的操作系统上部署多个应用程序。

太好了,对吧?记得这本书的开头,我们不得不根据操作系统进行配置和安装,主要是针对 Python 可执行文件、Postgres 服务器以及创建和激活虚拟环境的各种命令吗?使用 Docker,我们可以为容器设置一个单一的配置,并且这个配置可以在任何机器上运行。Docker 确保您的应用程序可以在任何环境中执行。然后,我们可以说 Docker 是一个用于在容器内构建、开发和部署应用程序的软件平台。它有以下优点:

  • 简约且便携:与需要完整操作系统、应用程序和依赖项副本的虚拟机(VMs)相比,这些可以占用大量空间,而 Docker 容器由于使用的镜像大小仅为 兆字节(MB),因此所需的存储空间更少。这使得它们启动速度快,易于在小型设备(如树莓派嵌入式计算机)上便携。

  • Docker 容器可扩展:因为它们轻量级,开发人员或 DevOps 可以基于容器启动大量服务,并使用 Kubernetes 等工具轻松控制扩展。

  • Docker 容器安全:Docker 容器中的应用程序是相互隔离运行的。因此,一个容器无法检查另一个容器中正在运行的过程。

在更好地理解了 Docker 是什么之后,我们现在可以继续将 Docker 集成到 Django 应用程序中。

将 Django 应用程序 Docker 化

在上一节中,我们定义了 Docker 及其优势。在本节中,我们将配置 Docker 以与 Django 应用程序一起使用。这将帮助你更好地理解 Docker 在底层是如何工作的。

添加 Docker 镜像

使用 Docker 的项目的一个特点是项目中存在名为 Dockerfile 的文件。Dockerfile 是一个包含构建 Docker 镜像所需所有命令的文本文件。Docker 镜像是一个只读模板,包含创建 Docker 容器的指令。

使用 Dockerfile 创建镜像是最流行的方法,因为你只需输入设置环境、安装包、执行迁移等所需的指令。这就是 Docker 非常便携的原因。例如,在我们的 Django 应用程序的情况下,我们将基于基于 Python 3.10 的现有镜像编写 Dockerfile,该镜像基于流行的 Alpine Linux 项目 (alpinelinux.org/)。选择这个镜像是因为它的大小很小,仅等于 5 MB。在 Dockerfile 中,我们还将添加安装 Python 和 Postgres 依赖项的命令,并进一步添加安装包的命令。让我们从以下步骤开始:

  1. 首先,在 Django 项目的根目录下创建一个名为 Dockerfile 的新文件,并添加第一行:

Dockerfile

FROM python:3.10-alpine
# pull official base image

大多数 Dockerfile 都以这一行开始。在这里,我们告诉 Docker 使用哪个镜像来构建我们的镜像。python:3.10-alpine 镜像存储在称为 Docker 仓库的地方。这是一个 Docker 镜像的存储和分发系统,你可以在网上找到最受欢迎的一个,称为 Docker Hub,网址为 hub.docker.com/

  1. 接下来,让我们设置工作目录。这个目录将包含运行中的 Django 项目的代码:

Dockerfile

WORKDIR /app
  1. 由于 Django 应用程序使用 Postgres 作为数据库,我们将 Postgres 和 Pillow 的所需依赖项添加到我们的 Docker 镜像中:

Dockerfile

# install psycopg2 dependencies
RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev
    jpeg-dev zlib-dev
  1. 然后,在 /app 工作目录中复制 requirements.txt 文件后,安装 Python 依赖项:

Dockerfile

# install python dependencies
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
  1. 之后,复制整个项目:

Dockerfile

# add app
COPY . .
  1. 最后,将容器的 8000 端口暴露出来,以便其他应用程序或机器可以访问,运行迁移,并启动 Django 服务器:

Dockerfile

EXPOSE 8000
CMD ["python", "manage.py", "migrate"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Dockerfile 文件将包含以下最终代码:

Dockerfile

# pull official base image
FROM python:3.10-alpine
# set work directory
WORKDIR /app
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# install psycopg2 dependencies
RUN apk update \
   && apk add postgresql-dev gcc python3-dev musl-dev
# install python dependencies
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
# copy project
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "migrate"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

我们刚刚写下了构建 Django 应用程序镜像的步骤。让我们使用以下命令构建镜像。

docker build -t django-postagram .

前面的命令使用 Dockerfile 构建一个新的容器镜像——这就是为什么命令末尾有一个点(.)的原因。它告诉 Docker 在当前目录中查找 Dockerfile-t 标志用于标记容器镜像。然后,我们使用我们编写的 Dockerfile 构建了一个带有 django-backend 标签的镜像。一旦镜像构建完成,我们现在可以通过运行以下命令在容器中运行应用程序:

docker run --name django-postagram -d -p 8000:8000 django-postagram:latest

让我们描述一下前面的命令:

  • --name 将设置 Docker 容器的名称

  • -d 使镜像以分离模式运行,这意味着它可以在后台运行

  • django-postagram 指定了要使用的镜像名称

在输入前面的命令后,你可以使用以下命令检查正在运行的容器:

docker container ps

你将得到类似的输出:

图 13.1 – 列出机器上的 Docker 容器

图 13.1 – 列出机器上的 Docker 容器

容器已创建,但看起来它运行得不太好。在你的浏览器中,访问 http://localhost:8000,你会注意到浏览器返回了一个错误页面。让我们检查 django-postagram 容器的日志:

docker logs --details django-postagram

命令将在终端中输出容器内部正在发生的事情。你将得到类似的输出:

图 13.2 – django-postagram 容器的日志

图 13.2 – django-postagram 容器的日志

嗯,这是很正常的。容器正在自己的网络上运行,并且无法直接访问主机机器的网络。

在上一章中,我们为 NGINX 和 Postgres 添加了服务并进行了配置。我们还需要为 NGINX 和 Postgres 的 Dockerfile 做同样的事情。坦白说:这开始变得有点多了。想象一下添加一个 Flask 服务、一个 Celery 服务,甚至是另一个数据库。根据你系统组件的数量 n,你需要 n 个 Dockerfile。这并不有趣,但幸运的是,Docker 提供了一个简单的解决方案,称为 Docker Compose。让我们更深入地了解一下。

使用 Docker Compose 进行多个容器

Docker Compose 是 Docker 团队开发和创建的工具,用于帮助定义多容器应用程序的配置。使用 Docker Compose,我们只需创建一个 YAML 文件来定义服务和启动每个服务的命令。它还支持容器名称、环境设置、卷等配置,一旦编写了 YAML 文件,您只需一个命令来构建镜像并启动所有服务。

让我们了解 Dockerfile 和 Docker Compose 之间的关键区别:Dockerfile 描述了如何构建镜像和运行容器,而 Docker Compose 用于运行 Docker 容器。最终,Docker Compose 仍然在底层使用 Docker,您通常至少需要一个 Dockerfile。让我们将 Docker Compose 集成到我们的工作流程中。

编写 docker-compose.yaml 文件

在编写 YAML 文件之前,我们不得不对 Dockerfile 进行一些修改。由于我们将从 docker-compose 文件中启动 Django 服务器,我们可以删除暴露端口、运行迁移和启动服务的那几行代码。在 Dockerfile 中删除以下代码行:

Dockerfile

EXPOSE 8000
CMD ["python", "manage.py", "migrate"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

一旦完成,请在项目的根目录下创建一个名为 docker-compose.yaml 的新文件。确保 docker-compose.yaml 文件和 Dockerfile 在同一目录下。docker-compose.yaml 文件将描述后端应用程序的服务。我们需要编写三个服务:

  • NGINX: 我们正在使用 NGINX 作为 Web 服务器。幸运的是,有一个官方镜像可供我们使用,以便快速编写配置。

  • Postgres: 对于 Postgres,也有一个官方镜像可用。我们只需添加数据库用户的环境变量即可。

  • django-backend: 这是我们所创建的后端应用程序。我们将使用 Dockerfile,这样 Docker Compose 就会为这个服务构建镜像。

让我们从添加 NGINX 服务开始编写 docker-compose.yaml 文件:

docker-compose.yaml

version: '3.8'
services:
 nginx:
   container_name: postagram_web
   restart: always
   image: nginx:latest
   volumes:
     - ./nginx.conf:/etc/nginx/conf.d/default.conf
     - uploads_volume:/app/uploads
   ports:
     - "80:80"
   depends_on:
     - api

让我们看看前面的代码中发生了什么,因为其他服务将遵循类似的配置。第一行设置了我们使用的文件格式,因此它与 Docker Compose 无关,只是与 YAML 相关。

之后,我们添加一个名为 nginx 的服务:

  • container_name 表示,嗯,容器的名称。

  • restart 定义了容器重启策略。在这种情况下,如果容器失败,则始终重启。

关于容器的重启策略,您还可以有:

  • no: 容器不会自动重启

  • on-failure[:max-retries]: 如果容器以非零退出代码退出,则重启容器,并为 Docker 守护进程重启容器提供最大尝试次数

  • unless-stopped: 如果容器不是被任意停止或由 Docker 守护进程停止,则始终重启容器

  • image: 这告诉 Docker Compose 使用 Docker Hub 上可用的最新 NGINX 镜像。

  • volumes 是一种持久化 Docker 容器生成和使用的数据的手段。如果删除或移除 Docker 容器,其所有内容将永远消失。如果你有日志、图像、视频或任何希望持久化到某处的文件,这并不是一个好的选择,因为每次你移除一个容器,这些数据都会消失。以下是语法:/host/path:/container/path

  • ports: 来自主机端口 80 的连接请求被重定向到容器端口 80。以下是语法:host_port:container_port

  • depends_on: 这告诉 Docker Compose 在启动服务之前等待某些服务启动。在我们的例子中,我们正在等待 Django API 启动后再启动 NGINX 服务器。

太好了!接下来,让我们添加 Postgres 服务的服务配置:

docker-compose.yaml

db:
 container_name: postagram_db
 image: postgres:14.3-alpine
 env_file: .env
 volumes:
   - postgres_data:/var/lib/postgresql/data/

我们这里有一些新的参数,称为 env_file,它指定了用于创建数据库和用户以及设置密码的环境文件的路径。让我们最后添加 Django API 服务:

docker-compose.yaml

api:
 container_name: postagram_api
 build: .
 restart: always
 env_file: .env
 ports:
   - "8000:8000"
 command: >
   sh -c "python manage.py migrate --no-input && gunicorn
          CoreRoot.wsgi:application --bind 0.0.0.0:8000"
 volumes:
  - .:/app
  - uploads_volume:/app/uploads
 depends_on:
  - db

Docker Compose 文件中的构建参数告诉 Docker Compose 在哪里查找 Dockerfile。在我们的例子中,Dockerfile 在当前目录中。Docker Compose 允许你有一个 command 参数。在这里,我们正在运行迁移并使用 Gunicorn 启动 Django 服务器,这是新的。gunicorn 是一个 Python gunicorn?大多数网络应用程序都使用 Apache 服务器,所以 gunicorn 主要是为了运行用 Python 构建的 Web 应用程序。

你可以通过运行以下命令在你的当前 Python 环境中安装包:

pip install gunicorn

但是,你需要将依赖项放入 requirements.txt 文件中,以便在 Docker 镜像中预先设置:

requirements.txt

gunicorn==20.1.0

最后,我们需要在文件末尾声明使用的卷:

docker-compose.yaml

volumes:
 uploads_volume:
 postgres_data:

我们刚刚编写了一个 docker-compose.yaml 文件。由于我们将在项目中使用环境变量,让我们在 settings.py 文件中更新一些变量。

在 Django 中配置环境变量

在代码中保留关于你的应用程序的敏感信息是一个坏习惯。对于项目中的 settings.py 文件中的 SECRET_KEY 设置和数据库设置来说,情况就是这样。这相当糟糕,因为我们已经把代码推送到 GitHub 上了。让我们纠正这个错误。

环境变量是一个其值在程序运行代码之外设置的变量。使用 Python,你可以从 .env 文件中读取文件。我们将使用 os 库来编写配置。所以,首先,在 Django 项目的根目录下创建一个 .env 文件,并添加以下内容:

.env

SECRET_KEY=foo
DATABASE_NAME=coredb
DATABASE_USER=core
DATABASE_PASSWORD=wCh29&HE&T83
DATABASE_HOST=postagram_db
DATABASE_PORT=5432
POSTGRES_USER=core
POSTGRES_PASSWORD=wCh29&HE&T83
POSTGRES_DB=coredb
ENV=DEV
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost

重要提示

SECRET_KEY 是你的 Django 项目中的一个重要变量,所以你需要确保你有一个长且复杂的字符链作为其值。你可以访问 djecrety.ir/ 生成一个新的字符链。

下一步是安装一个用于帮助您管理环境变量的包。这个包叫做 python-dotenv,它可以帮助 Python 开发者从 .env 文件中读取环境变量并将它们设置为环境变量。如果您打算在您的机器上再次运行项目,那么请使用以下命令将该包添加到您的实际 Python 环境中:

pip install python-dotenv

最后,将包添加到 requirements.txt 文件中,以便它可以在 Docker 镜像中安装。下面是 requirements.txt 文件的内容:

Django==4.0.1
psycopg2-binary==2.9.3
djangorestframework==3.13.1
django-filter==21.1
pillow==9.0.0
djangorestframework-simplejwt==5.0.0
drf-nested-routers==0.93.4
pytest-django==4.5.2
django-cors-headers==3.11.0
python-dotenv==0.20.0
gunicorn==20.1.0

一旦安装了 python-dotenv 包,我们就需要在 CoreRoot/settings.py 文件中编写一些代码。在这个文件中,我们将导入 python-dotenv 包并修改一些设置的语法,以便它能够支持读取环境变量:

CoreRoot/settings.py

from dotenv import load_dotenv
load_dotenv()

让我们重写 SECRET_KEYDEBUGALLOWED_HOSTSENV 等变量的值:

CoreRoot/settings.py

ENV = os.environ.get("ENV")
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
   "SECRET_KEY", default=
     "qkl+xdr8aimpf-&x(mi7)dwt^-q77aji#j*d#02-5usa32r9!y"
)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False if ENV == "PROD" else True
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="*").split(",")

os 包提供了一个对象来从用户机器检索环境变量。在 python-dotenv 强制加载环境变量之后,我们使用 os.environ.env 文件中读取值。最后,让我们添加 DATABASES 设置的配置:

CoreRoot/settings.py

DATABASES = {
   "default": {
       "ENGINE": "django.db.backends.postgresql_psycopg2",
       "NAME": os.getenv("DATABASE_NAME", "coredb"),
       "USER": os.getenv("DATABASE_USER", "core"),
       "PASSWORD": os.getenv("DATABASE_PASSWORD",
                             "wCh29&HE&T83"),
       "HOST": os.environ.get("DATABASE_HOST",
                              "localhost"),
       "PORT": os.getenv("DATABASE_PORT", "5432"),
   }
}

太好了!我们在 settings.py 文件中配置环境变量已经完成。现在我们可以继续编写 NGINX 的配置。

编写 NGINX 配置

NGINX 需要我们从侧面提供一些配置。如果机器的 HTTP 端口(默认为 80)上有请求,它应该将请求重定向到正在运行的 Django 应用的端口 8000。简单来说,我们将编写一个反向代理。代理是一个中介过程,它从客户端接收 HTTP 请求,将请求传递给一个或多个其他服务器,等待这些服务器的响应,然后将响应发送回客户端。

通过使用此过程,我们可以将 HTTP 端口 80 上的请求转发到 Django 服务器的端口 8000

在项目的根目录下创建一个名为 nginx.conf 的新文件。然后,让我们定义 HTTP 请求将被重定向到的上游服务器:

nginx.conf

upstream webapp {
   server postagram_api:8000;
}

上述代码遵循以下简单语法:

upstream upstream_name {
   server host:PORT;
}

重要提示

Docker 允许您使用 defined 的容器名称来引用容器的宿主机。在 NGINX 文件中,我们使用 postagram_api 而不是容器的 IP 地址(它可能会变化),对于数据库,我们使用 postagram_db

下一步是声明 HTTP 服务器的配置:

nginx.conf

server {
   listen 80;
   server_name localhost;
   location / {
       proxy_pass http://webapp;
       proxy_set_header X-Forwarded-For
         $proxy_add_x_forwarded_for;
       proxy_set_header Host $host;
       proxy_redirect off;
   }
   location /media/ {
    alias /app/uploads/;
   }
}

在服务器配置中,我们首先设置服务器的端口。在前面的代码中,我们使用端口 80。接下来,我们定义位置。在 NGINX 中,位置是一个块,它告诉 NGINX 如何处理来自特定 URL 的请求:

  • / URL 上的请求被重定向到 web 应用上游

  • /media/ URL 上的请求被重定向到 uploads 文件夹以提供文件

在 NGINX 配置就绪后,我们现在可以启动容器了。

启动 Docker 容器

让我们启动 Docker 容器。由于我们现在使用 Docker Compose 来编排容器,所以让我们使用以下命令来构建和启动容器:

docker compose up -d –build

此命令将启动 docker-compose.yaml 文件中定义的所有容器。让我们描述一下命令选项:

  • up:此选项构建、重新创建并启动容器

  • -d:此选项用于分离,意味着我们在后台运行容器

  • —build:此标志告诉 Docker Compose 在启动容器之前构建镜像

构建完成后,打开您的浏览器到 http://localhost,您应该看到以下内容:

图 13.3 – Docker 化的 Django 应用程序

图 13.3 – Docker 化的 Django 应用程序

我们已成功使用 Docker 容器化了 Django 应用程序。在容器内执行命令也是可能的,目前我们可以从在 postagram_api 容器中运行测试套件开始:

docker compose exec -T api pytest

在 Docker 容器中执行命令的语法是首先调用 exec 命令,然后跟 –T 参数以禁用 pseudo-tty 分配。这意味着在容器内运行的命令将不会连接到终端。最后,您可以添加容器服务名称,然后跟在容器中要执行的命令。

我们离使用 Docker 在 AWS 上部署又近了一步,但我们需要自动化它。在下一章中,我们将配置项目以使用 GitHub Actions 自动化 AWS 服务器上的部署。

摘要

在本章中,我们学习了如何将 Django 应用程序 Docker 化。我们首先了解了 Docker 以及其在现代应用程序开发中的应用。我们还学习了如何构建 Docker 镜像并使用此镜像运行容器——这使我们了解了使用 Dockerfile 进行 Docker 化的一些限制。这使我们进一步学习了 Docker Compose 以及它如何帮助我们通过一个配置文件管理多个容器。这反过来又引导我们使用 Docker 配置数据库和 NGINX 网络服务器以启动 Postagram API。

在下一章中,我们将为项目配置自动部署到 AWS,并使用我们编写的测试执行回归检查。

问题

  1. 什么是 Docker?

  2. 什么是 Docker Compose?

  3. 什么是 Docker 和 Docker Compose 之间的区别?

  4. 容器化和虚拟化之间的区别是什么?

  5. 什么是环境变量?

第十四章:自动化 AWS 部署

在上一章中,我们成功地将 Django 应用部署到了一个 EC2 实例上。然而,大多数部署都是手动完成的,我们在推送应用的新版本时并没有检查回归。有趣的是,所有部署都可以使用 GitHub Actions 自动化。

在本章中,我们将使用 GitHub Actions 自动部署到 AWS EC2 实例上,这样你就不必手动操作了。我们将探讨如何编写一个配置文件,该文件将在代码上运行测试以避免回归,并最终通过 安全套接字外壳 (SSH) 连接到服务器并执行脚本以拉取和构建代码的最新版本以及更新容器。为了回顾,我们将涵盖以下主题:

  • 解释 持续集成和持续 部署 (CI/CD)

  • 定义 CI/CD 工作流程

  • 什么是 GitHub Actions?

  • 配置后端以实现自动化部署

技术要求

本章的代码可以在 github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap14 找到。如果你使用的是 Windows 机器,请确保你的机器上安装了 OpenSSH 客户端,因为我们将生成 SSH 密钥对。

解释 CI/CD

在深入研究 GitHub Actions 之前,我们必须了解 CICD 这两个术语。在本节中,我们将了解每个术语并解释它们之间的区别。

CI

CI 是一种自动化将多个协作者的代码更改集成到单个项目中的实践。它还涉及到在任何时候可靠地发布对应用程序所做的更改的能力。没有 CI,我们就需要手动协调部署、将更改集成到应用程序中以及进行安全和回归检查。

这是一个典型的 CI 工作流程:

  1. 开发者从主分支创建一个新的分支,进行更改,提交,然后将它推送到分支。

  2. 推送完成后,代码会被构建,然后运行自动化测试。

  3. 如果自动化测试失败,开发团队会收到通知,并且下一步(通常是部署)会被取消。如果测试成功,那么代码就准备好在预发布或生产环境中部署了。

你可以找到许多用于 CI 管道配置的工具。你拥有 GitHub Actions、Semaphore、Travis CI 等工具。在这本书中,我们将使用 GitHub Actions 来构建 CI 管道,如果 CI 管道通过,我们就可以在 AWS 上部署它。现在让我们更深入地了解 CD。

CD

CD 与 CI 相关,但大多数时候代表 CI 管道成功通过后的下一步。CI 管道的质量(构建和测试)将决定发布的质量。有了 CD,一旦通过了 CI 步骤,软件就会自动部署到预发布或生产环境。

CD 管道的一个例子可能看起来像这样:

  1. 开发者创建一个分支,进行修改并推送更改,然后创建一个合并请求。

  2. 进行测试和构建以确保没有回归。

  3. 代码由另一位开发者进行审查,如果审查完成,则合并请求得到验证,然后进行另一套测试和构建。

  4. 之后,更改被部署到预发布或生产环境。

GitHub Actions 以及提到的其他 CI 工具也支持 CD。在更好地理解 CI 和 CD 之后,让我们定义我们将要配置的后端工作流程。

重要提示

如果您更深入地了解 CI/CD,您还会听到 持续交付 的概念;它是 持续部署 的进一步扩展。持续部署侧重于服务器的部署,而持续交付侧重于发布和发布策略。

定义 CI/CD 工作流程

在我们像上一章那样部署应用程序之前,我们需要写下我们将遵循的步骤,以及部署所需的工具。在本章中,我们将自动化 AWS 上的后端部署。基本上,每次我们在存储库的主分支上进行推送时,代码都应该在服务器上更新,并且容器应该更新并重新启动。

再次,让我们定义以下流程:

  1. 在服务器的主分支上进行了推送。

  2. 构建并启动 Docker 容器以运行测试。如果测试失败,则忽略以下步骤。

  3. 我们通过 SSH 连接到服务器,并运行一个脚本来从远程仓库拉取新的更改,构建容器,并使用 docker-compose 重新启动服务。

以下图表说明了典型的 CI/CD 工作流程:

图 14.1 – CI/CD 工作流程

图 14.1 – CI/CD 工作流程

这需要手动完成很多事情,幸运的是,GitHub 提供了一个有趣的功能,称为 GitHub Actions。现在我们已经对部署策略有了更好的了解,让我们更深入地探索这个功能。

什么是 GitHub Actions?

GitHub Actions 是由 GitHub 构建和开发的服务,用于自动化构建、测试和部署管道。使用 GitHub Actions,我们可以轻松实现如图 14.1 所示的 CI/CD 工作流程。在继续之前,请确保您的项目托管在 GitHub 上。

GitHub Actions 配置在一个文件中,必须存储在存储库中名为 .github/workflows 的专用目录中。为了更好的工作流程,我们还将使用 GitHub secrets 来存储部署信息,例如服务器的 IP 地址、SSH 密码短语和服务器用户名。让我们首先了解如何编写 GitHub Actions 工作流程文件。

如何编写 GitHub Actions 工作流程文件

工作流程文件存储在名为 .github/workflows 的专用目录中。这些文件使用的语法是 YAML 语法,因此工作流程文件具有 .yml 扩展名。

让我们更深入地了解工作流程文件的语法:

  • name: 这代表工作流程的名称。此名称通过在文件开头放置以下行来设置:

    name: Name of the Workflow
    
  • on: 这指定了将自动触发工作流程的事件。一个事件示例是推送、拉取请求或分支:

    on: push
    
  • jobs: 这指定了工作流程将执行的操作。您可以有多个任务,甚至可以有一些任务相互依赖:

    jobs:
    
     build-test:
    
       runs-on: ubuntu-latest
    
       steps:
    
       - uses: actions/checkout@v2
    
       - name: Listing files in a directory
    
         run: ls -a
    

在我们的 GitHub Actions 工作流程中,我们将有两个任务:

  • 一个名为build-test的任务,用于构建 Docker 容器并在其中运行测试

  • 一个名为deploy的任务,用于将应用程序部署到 AWS 服务器

应用程序的部署将取决于build-test任务的失败或成功。这是一种防止代码在生产环境中失败和崩溃的好方法。既然我们已经了解了 GitHub Actions 工作流程、YAML 语法以及我们想要为工作流程编写的任务,那么让我们编写 GitHub Actions 文件并配置服务器以实现自动部署。

配置后端以实现自动化部署

在前面的章节中,我们讨论了更多关于 GitHub Actions 文件语法以及我们必须编写的任务,以便为 Django 应用程序添加 CI 和 CD。让我们编写 GitHub Action 文件并配置后端以实现自动部署。

添加 GitHub Actions 文件

在项目的根目录下,创建一个名为.github的目录,并在该目录内创建另一个名为workflows的目录。在workflows目录内,创建一个名为ci-cd.yml的文件。此文件将包含 GitHub 动作的 YAML 配置。让我们首先定义工作流程的名称和将触发工作流程运行的事件:

.github/workflows/ci-cd.yml

name: Build, Test and Deploy Postagram
on:
 push:
   branches: [ main ]

每当主分支上有推送时,工作流程都会运行。让我们继续编写build-test任务。对于这个任务,我们将遵循三个步骤:

  1. 将环境变量注入到文件中。Docker 需要.env文件来构建镜像并启动容器。我们将向 Ubuntu 环境注入虚拟环境变量。

  2. 之后,我们将构建容器。

  3. 最后,我们在api容器上运行测试。

让我们从步骤开始:

  1. 让我们从编写任务和注入环境变量开始:

.github/workflows/ci-cd.yml

build-test:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v2
 - name: Injecting env vars
   run: |
     echo "SECRET_KEY=test_foo
           DATABASE_NAME=test_coredb
           DATABASE_USER=test_core
           DATABASE_PASSWORD=12345678
           DATABASE_HOST=test_postagram_db
           DATABASE_PORT=5432
           POSTGRES_USER=test_core
           POSTGRES_PASSWORD=12345678
           POSTGRES_DB=test_coredb
           ENV=TESTING
           DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
            " >> .env

测试可能会失败,因为我们还没有定义名为TEST_SECRETS的 GitHub 密钥。

图 14.2 – 测试 Github 密钥

图 14.2 – 测试 Github 密钥

  1. 接下来,让我们添加构建容器的命令:

.github/workflows/ci-cd.yml

- name: Building containers
 run: |
   docker-compose up -d --build
  1. 最后,让我们在api容器中运行pytest命令:

.github/workflows/ci-cd.yml

- name: Running Tests
 run: |
   docker-compose exec -T api pytest

太好了!我们已经完全编写了工作流程的第一个任务。

  1. 通过运行以下命令来推送代码,并查看它在 GitHub 上的运行情况:

    git push
    
  2. 前往 GitHub 检查您的仓库。您将在仓库详情中看到一个橙色徽章,表示工作流程正在运行:

图 14.3 – 运行 GitHub Actions

图 14.3 – 运行 GitHub Actions

  1. 点击橙色徽章以获取有关正在运行的流程的更多详细信息。流程应该通过,你将看到一个绿色的状态:

图 14.4 – 成功的 GitHub Action 任务

图 14.4 – 成功的 GitHub Action 任务

太好了!我们的 build-test 任务已成功运行,这意味着我们的代码可以在生产环境中部署。在编写 deploy 任务之前,让我们先为自动部署配置服务器。

配置 EC2 实例

是时候回到 EC2 实例并做一些配置,以便简化自动部署。以下是需要完成的任务列表,以便 GitHub Actions 可以自动为我们处理部署:

  • 使用密码短语生成一对 SSH 密钥(私钥和公钥)。

  • 将公钥添加到服务器的 authorized_keys

  • 将私钥添加到 GitHub Secrets 以供 SSH 连接重用。

  • 将 EC2 机器操作系统的用户名、IP 地址和 SSH 密码短语注册到 GitHub Secrets。

  • 在服务器上添加部署脚本。基本上,该脚本将从 GitHub 拉取代码,检查更改,并最终构建和重新运行容器。

  • 将所有内容封装起来并添加 deploy 任务。

这看起来有很多步骤,但这里有个好消息:你只需要做一次。让我们先从生成 SSH 凭据开始。

生成 SSH 凭据

生成 SSH 密钥的最佳实践是在本地机器上生成,而不是在远程机器上。在接下来的几行中,我们将使用终端命令。如果你在 Windows 机器上工作,请确保已经安装了 OpenSSH 客户端。以下命令是在 Linux 机器上执行的。让我们开始以下步骤:

  1. 打开终端并输入以下命令以生成 RSA 密钥对:

    ssh-keygen -t rsa -b 4096 -C "your_email@example.com"
    

图 14.5 – 生成 SSH 密钥

图 14.5 – 生成 SSH 密钥

  1. 接下来,复制公钥的内容并将其添加到远程 EC2 实例的 .ssh/authorized_keys 文件中。你可以使用鼠标进行复制粘贴,或者你可以输入以下命令:

    cat .ssh/postagramapi.pub | ssh username@hostname_or_ipaddress 'cat >> .ssh/authorized_keys'
    
  2. 然后,复制私钥的内容并将其添加到 GitHub Secrets:

图 14.6 – 将私钥注册到 GitHub Secrets

图 14.6 – 将私钥注册到 GitHub Secrets

你还需要为密码短语、EC2 服务器 IP 地址和 EC2 机器的操作系统用户名执行相同的操作:

图 14.7 – 存储库密钥

图 14.7 – 存储库密钥

太好了!我们已经配置了存储库中的密钥;现在我们可以在 GitHub Action 上编写 deploy 任务了。

添加部署脚本

使用 GitHub Actions 的好处是您可以在 GitHub Marketplace 上找到预配置的 GitHub Actions,并直接使用它们,而不是重新发明轮子。对于部署,我们将使用ssh-action GitHub 动作,该动作是为了允许开发者通过 SSH 执行远程命令而开发的。这完美符合我们的需求。

让我们在 GitHub 动作工作流程中编写deploy作业,并在 EC2 实例上编写部署脚本:

  1. .github/workflows/ci-cd.yml文件中,在文件末尾添加以下代码:

.github/workflows/ci-cd.yml

deploy:
  name: Deploying on EC2 via SSH
  if: ${{ github.event_name == 'push' }}
  needs: [build-test]
  runs-on: ubuntu-latest
  steps:
  - name: Deploying Application on EC2
    uses: appleboy/ssh-action@master
    with:
      host: ${{ secrets.SSH_EC2_IP }}
      username: ${{ secrets.SSH_EC2_USER }}
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      passphrase: ${{ secrets.SSH_PASSPHRASE }}
      script: |
        cd ~/.scripts
        ./docker-ec2-deploy.sh

在 EC2 实例上运行的脚本是对一个名为docker-ec2-deploy.sh的文件的执行。该文件将包含从 GitHub 仓库拉取代码并构建容器的 Bash 代码。

让我们连接到 EC2 实例并添加docker-ec2-deploy.sh代码。

  1. 在主目录下,创建一个名为docker-ec2-deploy.sh的文件。使用 Git 和 Docker 进行部署的过程将遵循以下步骤:

    1. 我们必须确保 GitHub 仓库中有有效的更改,以便继续构建和运行容器。如果 Git 拉取没有带来新更改,重新构建容器将是资源内存的浪费。以下是我们可以如何检查这一点的方法:
    #!/usr/bin/env bash
    
    TARGET='main'
    
    cd ~/api || exit
    
    ACTION_COLOR='\033[1;90m'
    
    NO_COLOR='\033[0m'
    
    echo -e ${ACTION_COLOR} Checking if we are on the target branch
    
    BRANCH=$(git rev-parse --abbrev-ref HEAD)
    
    if [ "$BRANCH" != ${TARGET} ]
    
    then
    
       exit 0
    
    fi
    
    1. 下一步,我们将执行git fetch命令以从 GitHub 仓库下载内容:
    # Checking if the repository is up to date.
    
    git fetch
    
    HEAD_HASH=$(git rev-parse HEAD)
    
    UPSTREAM_HASH=$(git rev-parse ${TARGET}@{upstream})
    
    if [ "$HEAD_HASH" == "$UPSTREAM_HASH" ]
    
    then
    
       echo -e "${FINISHED}"The current branch is up to date with origin/${TARGET}."${NO_COLOR}"
    
         exit 0
    
    fi
    

完成这些后,我们将通过比较HEAD哈希值和UPSTREAM哈希值来检查仓库是否是最新的。如果它们相同,则仓库是最新的。

  1. 如果HEADUPSTREAM哈希值不相同,我们将拉取最新更改,构建容器,并运行容器:
# If there are new changes, we pull these changes.
git pull origin main;
# We can now build and start the containers
docker compose up -d --build
exit 0;

太好了!我们现在可以给脚本执行权限:

chmod +x docker-ec2-deploy.sh

我们已经完成了。您可以将 GitHub 工作流程中做出的更改推送到 GitHub,自动部署作业将开始。

重要提示

根据仓库的类型(私有或公共),您可能需要在执行每个远程 git 命令时输入您的 GitHub 凭据,例如git pushgit pull等。确保您已使用 SSH 或 HTTPS 配置了凭据。您可以检查如何操作docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token

确保在 AWS 服务器项目的根目录下有一个.env文件。以下是一个您可以使用进行部署的.env文件示例。别忘了更改数据库凭据或密钥的值:

SECRET_KEY=foo
DATABASE_NAME=coredb
DATABASE_USER=core
DATABASE_PASSWORD=wCh29&HE&T83
DATABASE_HOST=localhost
DATABASE_PORT=5432
POSTGRES_USER=core
POSTGRES_PASSWORD=wCh29&HE&T83
POSTGRES_DB=coredb
ENV=PROD
DJANGO_ALLOWED_HOSTS=EC2_IP_ADDRESS,EC2_INSTANCE_URL

确保将EC2_IP_ADDRESSEC2_INSTANCE_URL替换为您的 EC2 实例的值。您还需要在端口80上允许 TCP 连接,以便在整个配置中允许 EC2 实例上的 HTTP 请求。

图 14.8 – 允许 HTTP 请求

图 14.8 – 允许 HTTP 请求

你也可以移除8000个配置,因为 NGINX 会自动处理将 HTTP 请求重定向到0.0.0.0:8000

在理解了 CI/CD 的概念,并解释和编写了 GitHub Actions 之后,你现在拥有了所有需要的工具,可以自动化在 EC2 实例和任何服务器上的部署。现在后端已经部署完成,我们可以继续部署 React 前端,不是在 EC2 实例上,而是在 AWS 的简单存储服务S3)上。

摘要

在本章中,我们最终使用 GitHub Actions 自动化了 Django 应用程序在 AWS 上的部署。我们探讨了 CI 和 CD 的概念,以及 GitHub Actions 如何允许配置这些概念。

我们编写了一个 GitHub 动作文件,其中包含构建和运行测试套件的作业,如果这些步骤成功,我们运行deploy作业,这仅仅是连接到 EC2 实例,并运行一个脚本来拉取更改,构建新镜像,并运行容器。

在下一章中,我们将学习如何使用像 AWS S3 这样的服务来部署 React 应用程序。

问题

  1. CI 和 CD 之间的区别是什么?

  2. 什么是 GitHub Actions?

  3. 什么是持续交付?

第十五章:在 AWS 上部署我们的 React 应用程序

在上一章中,我们使用 GitHub Actions 和 AWS EC2 实例的一些配置自动化了 Django 应用程序的部署。Postagram API 现在是实时状态,现在我们必须部署 React 应用程序,以便在互联网上提供完整的 Postagram 应用程序。

在本章中,我们将使用 AWS 简单存储服务S3)部署 React 应用程序,并使用 GitHub Actions 自动化部署。我们将涵盖以下主题:

  • React 应用程序的部署

  • 在 AWS S3 上部署

  • 使用 GitHub Actions 自动化部署

技术要求

对于本章,您需要在 AWS 上有一个账户。您还需要创建一个身份和访问管理IAM)用户并保存凭证。您可以通过遵循官方文档docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html#id_users_create_cliwpsapi来完成此操作。您可以在github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap15找到本章的代码。

React 应用程序的部署

使用 JavaScript 和 JSX 构建 React 应用程序。然而,为了让应用程序在互联网上可访问,我们需要一个浏览器可以解释和理解的应用程序版本,基本上是一个具有 HTML、CSS 和 JavaScript 的应用程序。

在开发模式下,React 提供了一个环境来检测警告,并提供检测和修复应用程序中问题的工具,以消除潜在的问题。这会给项目添加额外的代码,增加包的大小,导致应用程序更大、更慢。

由于用户体验(UX)的原因,仅在互联网上部署生产构建的应用程序至关重要。根据谷歌的研究,53%的用户如果网站加载时间超过 3 秒就会离开网站。因此,我们必须构建我们创建的 React 应用程序并部署生产版本。

什么是生产构建?

在开发过程中,React 应用程序以开发模式或本地模式运行。这是您可以查看所有警告和跟踪记录的地方,以防您的代码崩溃。生产模式要求开发者构建应用程序。此构建会压缩代码,优化资源(图像、CSS 文件等),生成更轻的源映射,并抑制开发模式中显示的警告消息。

因此,应用程序的包大小大幅减少,这提高了页面加载速度。在本章中,我们将构建一个可用于生产的应用程序,并将其部署到 AWS S3 作为静态网站。

在 AWS S3 上部署

AWS S3 是 AWS 最受欢迎的服务之一。它是一个基于云的存储服务,提供高性能、可用性、可靠性、安全性和令人难以置信的可扩展性潜力。AWS S3 主要用于存储静态资产,以便它们能够有效地分发到互联网上,由于其分发特性,AWS S3 适合托管静态网站。

在本章中,我们将创建一个 S3 存储桶,上传构建好的 React 应用程序的内容,并允许从互联网上公开访问。S3 存储桶是 AWS 中可用的一个公共存储资源,就像一个在线文件夹,你可以在这里存储对象(就像 Google Drive 上的文件夹一样)。在下一节中,我们将创建一个生产就绪版本的 React 应用程序。

创建 Postagram 的构建版本

我们可以使用一条命令创建 React 应用的构建版本:

Yarn build

yarn build 命令创建了一个 React 应用程序的静态文件包。这个包已经足够优化,可以用于生产。生产版本的 Postagram 应用程序将使用在线版本的 API。这意味着我们需要在 React 代码中进行一些调整,主要涉及代码中使用的 API URL。

在本书的 第二部分,即 使用 React 构建响应式 UI 中,我们使用本地主机服务器在端口 8000 上的数据构建了 React 应用程序。在本章中,情况将有所不同,我们将借此机会向 React 应用程序添加环境变量。将环境变量集成到 React 应用程序中非常简单。让我们在 Postagram React 应用程序中配置环境变量。

添加环境变量并构建应用程序

根据 Create React App 的文档关于环境变量的说明 (create-react-app.dev/docs/adding-custom-environment-variables/),

“你的项目可以消费在环境中声明的变量,就像它们在本地 JS 文件中声明一样。默认情况下,你将有一个 NODE_ENV 被定义,以及任何以 REACT_APP_ 开头的其他环境变量”。

要访问环境变量的值,我们将使用 process.env.REACT_APP_VALUE 语法,因为这些环境变量是在 process.env 上定义的。

在 React 项目的根目录下创建一个名为 .env 的文件。在这个文件中,添加以下内容以及你在 EC2 AWS 服务器上部署的 API URL 的名称:

REACT_APP_API_URL=https://name_of_EC2_instance.compute-1.amazonaws.com/api

然后,你需要修改 src/helpers/axios.jssrc/hooks/user.actions.js 中的某些代码片段。我们必须更新 baseURL 变量,使其从 .env 文件中读取值:

src/hooks/user.actions.js

function useUserActions() {
 const navigate = useNavigate();
 const baseURL = process.env.REACT_APP_API_URL;
 return {
   login,
   register,
   logout,
   edit,
 };

我们同样在 axios.js 文件上执行相同的操作:

src/helpers/axios.js

const axiosService = axios.create({
 baseURL: process.env.REACT_APP_API_URL,
 headers: {
   "Content-Type": "application/json",
 },
});
…
const refreshAuthLogic = async (failedRequest) => {
 return axios
   .post(
     "/auth/refresh/",
     {
       refresh: getRefreshToken(),
     },
     {
       baseURL: process.env.REACT_APP_API_URL,
...

太好了!现在可以构建应用程序了。运行以下命令:

yarn build

你将得到类似的结果:

图 15.1 – yarn build 命令的输出

图 15.1 – yarn build 命令的输出

构建可用在新创建的build目录中,您将找到以下内容:

图 15.2 – build 目录

图 15.2 – build 目录

使用生产就绪的 React 应用程序,我们可以在 S3 上部署应用程序。接下来,让我们创建一个 S3 存储桶并上传文件和文件夹。

在 S3 上部署 React 应用程序

我们有一个构建就绪的应用程序版本和一个针对生产优化的版本。在 S3 上部署之前,我们需要通过创建存储桶并在 AWS 上告知我们将托管静态网站来对 AWS S3 进行一些配置。在 AWS 控制台菜单中,选择 S3 服务并创建一个存储桶。按照以下步骤使用 S3 服务在 AWS 上部署 React 应用程序:

  1. 您需要输入一些配置,例如存储桶名称值等,如图所示:

图 15.3 – AWS S3 存储桶的常规配置

图 15.3 – AWS S3 存储桶的常规配置

  1. 之后,您需要禁用阻止所有公共访问设置,以便 React 应用程序对公众可见:

图 15.4 – 公共访问配置

图 15.4 – 公共访问配置

  1. 基本配置完成后,您可以继续创建 S3 存储桶。访问新创建的存储桶,选择属性选项卡,转到静态网站托管。在页面上,启用静态****网站托管

图 15.5 – 静态网站托管配置

图 15.5 – 静态网站托管配置

  1. 您还应该填写索引文档错误文档字段。这将有助于 React 应用程序的路由。保存更改,您将看到存储桶网站端点,这将是您网站的 URL:

图 15.6 – 静态网站托管配置完成

图 15.6 – 静态网站托管配置完成

  1. 最后,选择权限选项卡并选择存储桶策略。我们将添加一个策略以授予对存储桶的公共访问权限,如下所示:

    {
    
        "Version": "2012-10-17",
    
        "Statement": [
    
            {
    
                "Sid": "Statement1",
    
                "Effect": "Allow",
    
                "Principal": {
    
                    "AWS": "*"
    
                },
    
                "Action": "s3:GetObject",
    
                "Resource": "arn:aws:s3:::postagram/*"
    
            }
    
        ]
    
    }
    

在您的案例中,将 Postagram 替换为您的 React 应用程序的名称。

  1. 保存更改。您会注意到在存储桶名称旁边将出现一些新信息:

图 15.7 – 公开可访问徽章

图 15.7 – 公开可访问徽章

  1. 现在,点击 React 应用程序的build目录。上传完成后,您将得到类似的结果:

图 15.8 – 存储桶内容

图 15.8 – 存储桶内容

  1. 点击存储桶网站端点,您将在浏览器中访问 Postagram React 应用程序:

图 15.9 – 部署的 React 应用程序

图片

图 15.9 – 部署的 React 应用程序

太好了!我们已经使用 S3 服务在 AWS 上部署了一个 React 应用程序。你肯定会在 AWS EC2 实例上 Django 应用程序的 .env 文件中遇到 CORS_ALLOW_ORIGINS 环境变量。以下是如何定义环境变量的示例:

.env

CORS_ALLOW_ORIGINS="S3_WEBSITE_URL"

然后,在 Django 项目的 settings.py 文件中,将定义 CORS_ALLOW_ORIGINS 的行替换为以下内容:

CoreRoot/settings.py

...
CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
...

我们已经学习了如何配置存储桶,更改公共访问策略,并激活 AWS S3 的网站托管功能。然而,部署是手动完成的,并且在未来,如果您经常推送,每次手动上传更改可能会很麻烦。在下一节中,我们将探讨如何使用 GitHub Actions 自动化 React 应用程序的部署。

使用 GitHub Actions 自动化部署

在上一章中,我们探讨了 GitHub Actions 如何使部署流程对开发者来说更容易、更安全、更可靠。这就是为什么在本章中,我们也在使用 GitHub Actions 来自动化 React 应用程序的部署。

有一个名为 configure-aws-credentials 的 GitHub Action,用于 AWS。我们将使用此操作在工作流程中配置 AWS 凭据,以执行命令将 build 文件夹的内容上传到之前创建的 S3 桶。但在那之前,我们将遵循相同的 CI/CD 工作流程:

  1. 安装项目的依赖项。

  2. 运行测试以确保应用程序在生产环境中不会崩溃,并确保没有回归。

  3. 运行 build 命令以获得一个生产就绪的应用程序。

  4. 在 AWS S3 上部署。

让我们在存储库中添加一个新的工作流程文件,用于部署 React 应用程序。

重要提示

对于这本书,Django 应用程序和 React 应用程序位于同一个存储库中。这个选择是为了让您更容易地浏览代码和项目。因此,您将在 .github/workflows 目录中找到两个工作流程。如果您已经将 Django 应用程序的代码和 React 项目的代码拆分到不同的存储库中,请确保不要混合 GitHub Actions 文件。

编写工作流程文件

.github/workflows 目录中,创建一个名为 deploy-frontend.yml 的文件。像往常一样,编写 GitHub Actions 文件的第一步是定义工作流程的名称和将触发此工作流程的条件:

.github/workflows/deploy-frontend.yml

name: Build and deploy frontend
on:
 push:
   branches: [ main ]

然后我们创建一个名为 build-test-deploy 的工作任务。在这个工作任务中,我们将编写命令来安装 React 依赖项,运行测试,构建项目,并将应用程序部署到 S3。让我们首先注入环境变量:

.github/workflows/deploy-frontend.yml

jobs:
 build-test-deploy:
   name: Tests
   runs-on: ubuntu-latest
   defaults:
     run:
       working-directory: ./social-media-react
   steps:
     - uses: actions/checkout@v2
     - name: Injecting environment variables
       run: echo "REACT_APP_API_URL=${{ secrets.API_URL }}"
            >> .env

我们现在可以添加安装依赖项、运行测试和构建应用程序的命令:

.github/workflows/deploy-frontend.yml

     - name: Installing dependencies
       run: yarn install
     - name: Running tests
       run: yarn test
     - name: Building project
       run: yarn build

我们还可以添加 AWS 凭证操作来配置工作流程中的 AWS 凭证并运行命令以部署到 S3:

.github/workflows/deploy-frontend.yml

     - name: Configure AWS Credentials
       uses: aws-actions/configure-aws-credentials@v1
       with:
         aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID
                             }}
         aws-secret-access-key:
           ${{ secrets.AWS_SECRET_ACCESS_KEY }}
         aws-region: us-west-1
     - name: Deploy to S3 bucket
       run: aws s3 sync ./build/ s3://postagram --delete

在最后一个命令中,我们正在将build目录的内容上传到 Postagram 存储桶。在使用此配置时,请确保使用您自己的 S3 存储桶名称。GitHub 操作文件已编写并可部署。提交更改并将它们推送到 GitHub 仓库。

恭喜!您已使用 GitHub Actions 将 React 应用程序部署到 AWS S3。

我们已成功部署了在本书中构建的全栈应用程序。我们在 AWS 实例上部署了 Django API 应用程序,在 AWS S3 上部署了 React 前端,并使用 GitHub Actions 自动化了 CI/CD 管道。然而,在完全上线之前,我们需要对后端和前端进行一些优化,使用 HTTPS 确保在 AWS 上部署的应用程序版本的安全,并更多讨论缓存和 SQL 查询优化。

摘要

在本章中,我们已在 AWS 上部署了前端 React 应用程序。我们探讨了 AWS S3 服务,这是 AWS 为在互联网上存储对象而创建和开发的。我们学习了如何向 React 应用程序添加环境变量,以及如何通过构建应用程序来拥有一个生产就绪的捆绑包。

生产捆绑包已用于在 AWS S3 上部署,使用存储桶并配置存储桶以进行静态网站托管。为了使部署过程顺利,我们已创建 GitHub 操作来自动化 React 前端项目的 CI/CD 管道,从构建和测试到在 AWS S3 上部署应用程序。

在下一章中,我们将专注于通过优化查询、添加缓存、添加登出端点以及使用 HTTPS 确保服务器与客户端之间的通信来优化 Django API 和 React 前端。

问题

  1. 什么是 AWS S3?

  2. 如何在 AWS 上创建 IAM 用户?

  3. 构建 React 应用程序使用的命令是什么?

  4. Node.js 项目中的环境变量是从哪里检索的?

第十六章:性能、优化和安全

在本书的前几章中,我们从零开始创建了一个全栈应用程序,首先使用 Django 和 Django REST Framework 构建和创建了一个 REST API,然后使用 React 创建了一个与 API 通信的 Web 界面。我们还已经在 AWS EC2 和 AWS S3 等服务上部署了应用程序。然而,我们需要进一步调查在互联网上部署应用程序的一些重要方面,例如性能检查、查询优化、前端优化,以及最终的安全方面。

在本章中,我们将学习如何通过减少 SQL 查询和使用更快的 API 响应来创建一个高性能的 API,如何使用 AWS CloudFront 通过 HTTPS 提供 API 和 React 前端,以及如何使用 API 登出用户。在本章中,我们将涵盖以下内容:

  • 撤销 JWT 令牌

  • 添加缓存

  • 优化 React 应用程序的部署

  • 使用 AWS CloudFront 通过 HTTPS 保护已部署的应用程序

技术要求

对于本章,你需要有一个活跃的 AWS 账户,可以访问 S3、EC2 和 CloudFront 等服务。你还可以在本章的代码:github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap16中找到代码。

撤销 JWT 令牌

在本书中,我们使用JSON Web Tokens(JWTs)实现了一个身份验证系统,因为它是一个无状态的身份验证系统,所以大部分的身份验证流程都是由前端处理的。如果我们想从 Postagram React 应用程序中登出用户,我们必须从浏览器的本地存储中清除令牌,用户将自动重定向到登录页面。但是即使令牌从浏览器中删除,它们仍然有效。

刷新令牌的有效期更长,因此如果黑客获得了刷新令牌,他们仍然可以请求访问令牌并使用他人的身份进行 HTTP 请求。为了避免这种情况,我们将添加一个登出功能,从服务器端使访问和刷新令牌失效。

用于在 Django REST API 上添加 JWT 身份验证的包(djangorestframework-simplejwt)支持黑名单令牌,这正是我们需要的完美功能。让我们设置登出功能所需的配置,并将该功能添加到 Django REST API 中。

添加登出端点

在本节中,我们将为 Django 应用程序编写一些代码以添加一个登出端点:

  1. 在项目的settings.py文件中,将以下条目添加到INSTALLED_APPS列表中:

CoreRoot/settings.py

...
"corsheaders",
"rest_framework_simplejwt.token_blacklist",
...
  1. 然后,在core/auth/viewsets目录中创建一个名为logout.py的文件。此文件将包含viewsets的代码以及黑名单令牌的逻辑。

  2. 在此文件中,添加所需的导入并定义LogoutViewSet类:

core/auth/viewsets/logout.py

from rest_framework_simplejwt.tokens import RefreshToken, TokenError
from rest_framework import viewsets, status, permissions
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
class LogoutViewSet(viewsets.ViewSet):
   authentication_classes = ()
   permission_classes = (permissions.IsAuthenticated,)
   http_method_names = ["post"]

注销端点将只接受POST请求,因为客户端需要在POST请求体中传递刷新令牌。我们还指定只有经过认证的用户才有权访问此端点。

  1. 让我们来编写LogoutViewSet类的create方法:

core/auth/viewsets/logout.py

...
class LogoutViewSet(viewsets.ViewSet):
...
   def create(self, request, *args, **kwargs):
       refresh = request.data.get("refresh")
       if refresh is None:
           raise ValidationError({"detail":
             "A refresh token is required."})
       try:
           token = RefreshToken(request.data.get(
             "refresh"))
           token.blacklist()
           return Response(
             status=status.HTTP_204_NO_CONTENT)
       except TokenError:
           raise ValidationError({"detail":
             "The refresh token is invalid."})

在前面的代码中,我们确保请求体中存在刷新令牌。否则,我们将引发错误。一旦完成验证,我们将黑名单逻辑封装在try/except块中:

  • 如果令牌有效,则将令牌加入黑名单,并返回一个带有204 HTTP状态码的响应。

  • 如果存在与令牌相关的错误,则令牌无效,我们将返回一个验证错误。

  1. 让我们别忘了在routers.py文件中添加新创建的ViewSet并注册一个新的路由:

core/routers.py

...
from core.auth.viewsets import (
   RegisterViewSet,
   LoginViewSet,
   RefreshViewSet,
   LogoutViewSet,
)
...
router.register(r"auth/logout", LogoutViewSet, basename="auth-logout")
  1. 太好了!为了遵循构建软件的最佳实践,我们必须在core/auth/tests.py文件中为新增的路由添加一个测试:

core/auth/tests.py

...
def test_logout(self, client, user):
   data = {"username": user.username,
           "password": "test_password"}
   response = client.post(self.endpoint + "login/",
                          data)
   assert response.status_code == status.HTTP_200_OK
   client.force_authenticate(user=user)
   data_refresh = {"refresh":
     response.data["refresh"]}
   response = client.post(self.endpoint + "logout/",
     data_refresh)
   assert response.status_code ==
     status.HTTP_204_NO_CONTENT

在前面的代码中,我们登录以获取刷新令牌并强制用户进行认证,以便我们可以访问注销端点。之后,我们确保在注销成功时返回了正确的状态码。

  1. 使用pytest命令运行测试。如果你使用 Docker,则可以使用此命令运行测试:

     docker-compose exec -T api pytest
    

当注销端点准备就绪后,我们现在可以对 React 应用程序中的认证逻辑(主要是注销逻辑)进行一些修改。

使用 React 处理注销

我们已经在 React 应用程序中对注销进行了一定程度的处理,只是从本地存储中删除了令牌。这里没有太多需要修改的,我们只需添加一个函数来向 API 发起请求,如果这个请求成功,我们将删除令牌和用户从浏览器的本地存储中。React 应用程序上当前的注销逻辑在NavigationBar组件中处理:

src/components/NavBar.jsx

...
             <NavDropdown.Item
               onClick={userActions.logout}>
               Logout
             </NavDropdown.Item>
...

useActions钩子函数内部,让我们调整logout方法,在删除用户之前发起 API 调用:

src/hooks/user.actions.js

...
 // Logout the user
 function logout() {
   return axiosService
     .post(`${baseURL}/auth/logout/`,
           { refresh: getRefreshToken() })
     .then(() => {
       localStorage.removeItem("auth");
       navigate("/login");
     });
 }

一旦完成,让我们在NavigationBar组件中创建一个函数来处理 API 错误的情况。我们将在页面上显示一个带有错误信息的 toast HTML 块:

src/components/NavBar.jsx

import React, { useContext } from "react";
import { Context } from "./Layout";
...
function NavigationBar() {
 const { setToaster } = useContext(Context);
 const userActions = useUserActions();
 const user = getUser();
 const handleLogout = () => {
   userActions.logout().catch((e) =>
     setToaster({
       type: "danger",
       message: "Logout failed",
       show: true,
       title: e.data?.detail | "An error occurred.",
     })
   );
 };
...

太好了!我们的全栈应用程序现在支持注销。在下一节中,我们将讨论在线部署项目时的一个常见话题,缓存。

添加缓存

在软件计算中,缓存是将文件的副本存储在缓存中以便更快访问的过程。缓存是一个临时存储位置,用于存储数据、文件以及有关经常请求的软件的信息。

缓存的优秀示例和解释来自彼得·切斯特,他在一次演讲中向听众提出了一个问题:“3,485,250 除以 23,235 等于多少?”大家沉默了一会儿,但有人拿出计算器喊出了答案“150!”然后,彼得·切斯特再次提出了相同的问题,这一次,大家都能立即回答这个问题。

这是对缓存概念的一个很好的演示:计算只由机器执行一次,然后将其保存在快速内存中以 加快访问速度

这是一个被公司和主要社交媒体网站广泛使用的一个概念,其中数百万用户访问相同的帖子、视频和文件。每当数百万人都想访问相同的信息时,直接击中数据库将会非常原始。例如,如果一条推文在 Twitter 上开始流行,它会被自动移动到缓存存储中以便快速访问。而且,如果你有一个像金·卡戴珊这样的影响者将图片发布在 Instagram 上,你应该预期会有很多对这个图片的请求。因此,缓存在这里可以很有用,以避免对数据库进行数千次查询。

总结一下,缓存带来的以下好处:

  • 减少加载时间

  • 减少带宽使用

  • 减少数据库上的 SQL 查询

  • 减少停机时间

既然我们已经对缓存及其好处有了了解,我们可以使用 Django 甚至 Docker 来实现这一概念。但在那之前,让我们快速讨论一下缓存给应用程序带来的复杂性。

缓存的缺点

你已经知道使用缓存的优点,尤其是如果你的应用程序正在扩展或者你想要提高加载时间并减少成本。然而,缓存会给你的系统带来一些复杂性(它也可能取决于你正在开发的应用程序类型)。如果你的应用程序基于新闻或动态,你可能会遇到麻烦,因为你将需要定义一个良好的缓存架构。

一方面,你可以通过在一段时间内向用户展示相同的内容来减少加载时间,但与此同时,你的用户可能会错过更新,也许是一些重要的更新。在这里,缓存失效就派上用场了。

缓存失效是声明缓存内容无效或过时的过程。内容被失效,因为它不再被标记为文件的最新版本。有一些方法可以用来使缓存失效,如下所示:

  • 清除(刷新):缓存清除会立即从缓存中移除内容。当内容再次被请求时,它会在返回给客户端之前存储在内存缓存中。

  • 刷新:缓存刷新包括从服务器刷新相同的内容,并用从服务器获取的新版本替换缓存中存储的内容。这是在 React 应用程序中使用state-while-revalidateSWR)完成的。每次创建帖子时,我们都会调用一个刷新函数来再次从服务器获取数据。

  • 禁止:缓存禁止不会立即从缓存中删除内容。相反,内容会被标记为黑名单。然后,当客户端发起请求时,它会与黑名单内容进行匹配,如果找到匹配项,则会再次获取新内容并在内存缓存中更新,然后再返回给客户端。

在理解了缓存的缺点以及如何使缓存失效之后,你已经为将缓存添加到 Django 应用程序做好了充分的准备。在下一节中,让我们将缓存添加到 Postagram 的 Django API 中。

将缓存添加到 Django API

在前面的段落中,我们已经探讨了缓存、其优点以及该概念的缺点。现在,是时候在我们的 Django 应用程序中实现缓存了。Django 为缓存提供了有用的支持,使得在 Django 中配置缓存变得简单直接。让我们根据你的环境开始进行必要的配置。

配置 Django 以进行缓存

在 Django 中使用缓存需要配置一个内存缓存。为了实现最快的读写访问,最好使用不同于 SQL 数据库的数据存储解决方案,因为众所周知,SQL 数据库比内存数据库慢(当然,这也取决于你的需求)。在这本书中,我们将使用 Redis。Redis 是一个开源的内存数据存储,用作数据库、缓存、流引擎和消息代理。

我们将回顾你需要进行的配置,以便在你的 Django 项目中开始使用 Redis,无论你是否使用 Docker。然而,对于部署,我们将使用 Docker 来配置 Redis。

因此,如果你不打算使用 Docker,你可以通过以下链接安装 Redis:redis.io/download/

重要提示

如果你在一个 Linux 环境中工作,你可以使用sudo service redis-server status命令来检查服务是否正在运行。如果服务未激活,请使用sudo service redis-server start命令来启动 Redis 服务器。如果你使用 Windows,你需要安装或启用 WSL2。你可以在此处了解更多信息:redis.io/docs/getting-started/installation/install-redis-on-windows/

在你的机器上安装完成后,你可以通过 Django 项目的settings.py文件中的CACHES设置来配置缓存:

CoreRoot/settings.py

...
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'OPTIONS': {
            'CLIENT_CLASS':
              'django_redis.client.DefaultClient',
        }
    }
}

此配置将需要安装一个名为django-redis的 Python 包。通过运行以下命令来安装它:

pip install django-redis

如果你使用 Docker,你只需要添加以下配置:

  1. django-redis包添加到requirements.txt文件中:

requirements.txt

django-redis==5.2.0
  1. 添加docker-compose.yaml配置。我们将在 Docker 配置中添加一个新的镜像,以确保 Django 应用程序在 API 服务开始运行之前需要redis-server已准备好:

docker-compose.yaml

services:
 redis:
   image: redis:alpine
…
api:
...
 depends_on:
  - db
  - redis
...
  1. 太好了!在 Django 项目的settings.py文件中添加以下自定义后端:

CoreRoot/settings.py

CACHES = {
   "default": {
       "BACKEND": "django_redis.cache.RedisCache",
       "LOCATION": "redis://redis:6379",
       "OPTIONS": {
           "CLIENT_CLASS":
             "django_redis.client.DefaultClient",
       },
   }
}

您会注意到这里我们使用redis作为主机而不是127.0.0.1。这是因为,在使用 Docker 时,您可以使用服务名称作为主机。这是一个更好的解决方案;否则,您将不得不为服务配置静态 IP 地址。

重要提示

如果您想了解如何使用 Docker 为容器分配静态 IP 地址的更多信息,您可以阅读以下资源:www.howtogeek.com/devops/how-to-assign-a-static-ip-to-a-docker-container/

太好了!现在我们已经为 Django 配置了缓存,让我们为 Postagram 应用程序构建缓存系统。

在端点上使用缓存

缓存很大程度上取决于您希望缓存数据多长时间的业务需求。嗯,Django 提供了许多缓存级别:

  • 按站点缓存:这使您能够缓存整个网站。

  • 模板片段缓存:这使您能够缓存网站的一些组件。例如,您可以决定只缓存页脚。

  • 按视图缓存:这使您能够缓存单个视图的输出。

  • 低级缓存:Django 提供了一个 API,您可以使用它直接与缓存进行交互。如果您想根据一系列操作产生某种行为,这很有用。例如,在这本书中,如果帖子被更新或删除,我们将更新缓存。

现在我们对 Django 提供的缓存级别有了更好的了解,让我们为 Postagram API 定义缓存需求。

我们的要求是,如果对评论或帖子进行了删除或更新,缓存将被更新。否则,我们将返回缓存中的相同信息给用户。

这可以通过多种方式实现。我们可以使用 Django 信号或直接向PostComment类的管理器添加自定义方法。让我们选择后者。我们将覆盖AbstractModel类的savedelete方法,这样如果PostComment对象有更新,我们将更新缓存。

core/abstract/models.py文件中,在导入之后在文件顶部添加以下方法:

core/abstract/models.py

from django.core.cache import cache
...
def _delete_cached_objects(app_label):
   if app_label == "core_post":
       cache.delete("post_objects")
   elif app_label == "core_comment":
       cache.delete("comment_objects")
   else:
       raise NotImplementedError

上一段代码中的函数接受一个应用程序标签,并根据此app_label的值,我们使相应的缓存失效。目前,我们只支持对帖子或评论进行缓存。注意函数名称前有一个下划线_。这是一个编码约定,表示此方法为私有,不应在声明它的文件外部使用。

AbstractModel类中,我们可以覆盖save方法。在save方法执行之前,我们将使缓存失效。这意味着在createupdate等操作中,缓存将被重置:

core/abstract/models.py

class AbstractModel(models.Model):
...
   def save(
       self, force_insert=False, force_update=False,
       using=None, update_fields=None
   ):
       app_label = self._meta.app_label
       if app_label in ["core_post", "core_comment"]:
           _delete_cached_objects(app_label)
       return super(AbstractModel, self).save(
           force_insert=force_insert,
           force_update=force_update,
           using=using,
           update_fields=update_fields,
       )

在前面的代码中,我们从模型的_meta属性中检索app_label。如果它对应于core_postcore_comment,则失效缓存,其余指令可以继续。让我们为delete方法做同样的事情:

Core/abstract/models.py

class AbstractModel(models.Model):
…
   def delete(self, using=None, keep_parents=False):
       app_label = self._meta.app_label
       if app_label i" ["core_p"st", "core_comm"nt"]:
           _delete_cached_objects(app_label)
       return super(AbstractModel, self).delete(
         using=using, keep_parents=keep_parents)

太好了。模型上的缓存失效逻辑已经实现。让我们为core_post应用程序和core_comment应用程序的视图集添加缓存数据检索的逻辑。

从缓存中检索数据

缓存失效已经就绪,因此我们可以自由地从帖子端点和评论端点的缓存中检索数据。让我们从PostViewSet的部分代码开始写起,而CommentViewSet的代码将是相同的。作为一个小练习,您可以编写检索评论缓存的逻辑。

PostViewSet类内部,我们将重写list()方法。在Django REST frameworkDRF)开源仓库中,代码看起来是这样的:

"""List a queryset"""
def list(self, request, *args, **kwargs):
   queryset = self.filter_queryset(self.get_queryset())
   page = self.paginate_queryset(queryset)
   if page is not None:
       serializer = self.get_serializer(page, many=True)
       return self.get_paginated_response(serializer.data)
   serializer = self.get_serializer(queryset, many=True)
   return Response(serializer.data)

在前面的代码中,调用queryset以检索数据,然后对这个queryset调用进行分页、序列化,并在Response对象中返回。让我们稍微调整一下这个方法:

core/post/viewsets.py

class PostViewSet(AbstractViewSet):
...
   def list(self, request, *args, **kwargs):
       post_objects = cache.get("post_objects")
       if post_objects is None:
           post_objects =
             self.filter_queryset(self.get_queryset())
           cache.set("post_objects", post_objects)
       page = self.paginate_queryset(post_objects)
       if page is not None:
           serializer = self.get_serializer(page,
                                            many=True)
           return self.get_paginated_response(
             serializer.data)
       serializer = self.get_serializer(post_objects,
                                        many=True)
       return Response(serializer.data)

在前面的代码中,我们不是直接在数据库上查找,而是检查缓存。如果对数据库的查询中post_objectsNone,则在缓存中保存queryset,并最终返回给用户缓存对象。

如您所见,这个过程非常简单。您只需要有一个健壮的缓存策略。您可以作为一个练习对CommentViewSet做同样的事情。您可以通过这个链接检查代码以比较您的结果:github.com/PacktPublishing/Full-stack-Django-and-React/blob/chap16/core/comment/viewsets.py.

在本节中,我们探讨了缓存的优点,并在 Django 应用程序中实现了缓存。在下一节中,我们将看到如何使用如webpack等工具优化 React 构建。

优化 React 应用程序构建

在上一章中,我们成功构建了 React 应用程序并在 AWS S3 上进行了部署。然而,在优化和性能方面,我们本可以做得更好。在本节中,我们将使用著名的 webpack 模块构建器来优化 Postagram 的 React 构建。

在 React 中使用 webpack 有很多优点:

  • 它加快了开发和构建时间:在开发中使用 webpack 可以提高 React 快速重新加载的速度。

  • 它提供了代码压缩:Webpack 自动最小化代码而不改变其功能。这导致浏览器端的加载速度更快。

  • 代码分割:Webpack 将 JavaScript 文件转换为模块。

  • 它消除了无效资产:Webpack 只构建您的代码使用和需要的图片和 CSS。

让我们从将 webpack 集成到项目中开始。

集成 webpack

按照以下步骤将 webpack 集成到你的项目中:

  1. 在 React 项目内部,运行以下命令以添加 webpackwebpack-cli 包:

    yarn add -D webpack webpack-cli
    
  2. 安装完成后,修改 package.json 脚本:

package.json

...
"scripts": {
    "start": "react-scripts start",
    "build": "webpack --mode production",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
...

此外,我们还需要安装 Babel,这是一个 JavaScript 编译器,它将下一代 JavaScript 代码转换为浏览器兼容的 JavaScript。

  1. 在 React 项目中,Babel 将将 React 组件、ES6 变量和 JSX 代码转换为常规 JavaScript,以便旧浏览器可以正确渲染组件:

    yarn add -D @babel/core babel-loader @babel/preset-env @babel/preset-react
    

babel-loader 是 Babel 的 webpack 加载器,babel/preset-env 将 JavaScript 编译为 ES5,而 babel/preset-react 用于将 JSX 编译为 JS。

  1. 然后创建一个名为 .babelrc 的新文件:

    {
    
      "presets": ["@babel/preset-env",
    
                  "@babel/preset-react"]
    
    }
    
  2. 然后创建一个名为 webpack.config.js 的新文件。此文件将包含 webpack 的配置。在编写配置之前,添加一些用于优化 HTML、CSS 和复制文件的插件:

    yarn add -D html-webpack-plugin html-loader copy-webpack-plugin
    
  3. 然后在 webpack.config.js 上添加以下配置:

webpack.config.js

const path = require("path");
const HtmlWebPackPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
module.exports = {
 module: {
   rules: [
     {
       test: /\.(js|jsx)$/,
       exclude: /node_modules/,
       use: {
         loader: "babel-loader",
       },
     },
     {
       test: /\.css$/i,
       use: ["style-loader", "css-loader"],
     },
   ],
 },
};

上面的代码告诉 webpack 将所有 .js.jsx 文件通过 babel-loader 传递。

  1. 让我们添加另一个名为 resolve 的配置,以生成所有可能的模块路径。例如,webpack 将继续查找这些路径,直到找到文件:

webpack.config.js

...
 resolve: {
   modules: [path.resolve(__dirname, "src"),
             "node_modules"],
   extensions: ["", ".js", ".jsx"],
 },
};
  1. 让我们添加我们将在这个项目中使用的插件的配置:

webpack.config.js

...
 plugins: [
   new HtmlWebPackPlugin({
     template: "./public/index.html",
     filename: "./index.html",
   }),
   new CopyPlugin({
     patterns: [
       {
         from: "public",
         globOptions: {
           ignore: ["**/*.html"],
         },
       },
     ],
   }),
   new webpack.DefinePlugin({ process: {env: {}} }),
 ],
 output: {
   publicPath: '.',
 },
};

在前面的代码中,我们为以下插件添加了配置:

  • html-loader:这将通过 html-loader 传递 HTML 文件

  • copy:这将把公共文件的内容复制到 dist 文件中

  • define:此插件声明 process 对象,这样我们就可以在生产环境中访问环境变量

  1. 完成后,运行 build 命令:

    yarn build
    

Webpack 将接管并在 dist 目录中构建 React 应用程序:

图 16.1 – dist 目录的内容

图 16.1 – dist 目录的内容

太好了!你可以将所做的更改推送到 GitHub,代码将在 AWS S3 上部署。为了使测试和构建更快,我们将包管理器从 yarn 更改为 pnpm。下一节是可选的,但它将帮助你为你的 React 应用程序实现更快的构建。

使用 pnpm

pnpmnpm JavaScript 包管理器的替代品,它建立在 npm 之上,并且更快、更高效。它提供了诸如磁盘空间效率、改进的速度和更好的安全性等优势。如果你想要在构建和 GitHub Actions 上节省时间,pnpm 包管理器是你要使用的。

让我们在我们的机器上安装 pnpm

npm install -g pnpm

之后,我们可以生成一个 pnpm-lock.yaml 文件。我们可以从另一个管理器的锁文件中生成此文件,在我们的例子中,是从 yarn.lock 文件中:

pnpm import

图 16.2 – pnpm 导入的结果

图 16.2 – pnpm 导入的结果

在 React 项目的目录中会生成一个新文件。然后,修改 deploy-frontend.yml 文件以配置 pnpm 的使用:

.github/workflows/deploy-frontend.yml

jobs:
 test:
   name: Tests
   runs-on: ubuntu-latest
   defaults:
     run:
       working-directory: ./social-media-react
   steps:
     - uses: actions/checkout@v3
     - uses: pnpm/action-setup@v2.2.4
       with:
         version: 7
     - name: Use Node.js 16
       uses: actions/setup-node@v3
       with:
         node-version: 16
         cache: 'pnpm'
         cache-dependency-path:
           ./social-media-react/pnpm-lock.yaml

之后,只需在 deploy-frontend.yml 文件中将 yarn 替换为 pnpm。您将注意到 React 应用程序的构建速度更快。

在本节中,我们介绍了 pnpm 和 webpack 以及它们如何提高 React 应用程序的性能。在下一节中,我们将学习如何使用 AWS CloudFront 保护 HTTP 请求。

使用 AWS CloudFront 通过 HTTPS 保护已部署的应用程序

当我们在 AWS S3 上部署了后端和前端后,应用程序通过 HTTP 提供服务。基本上,我们的全栈应用程序在互联网上没有安全措施,我们容易受到攻击。根据 Open Web 应用安全项目OWASP)对不安全传输的描述(owasp.org/www-community/vulnerabilities/Insecure_Transport),我们的应用程序容易受到以下攻击:

  • 针对登录凭证、会话 ID 和其他敏感信息的攻击

  • 通过在浏览器中 URL 的开头输入 HTTP 而不是 HTTPS 来绕过 安全套接字层SSL)协议

  • 向用户发送未经保护的认证页面 URL,诱使他们通过 HTTP 进行认证

AWS EC2 和 AWS S3 默认不通过 HTTPS 提供内容。但 AWS 还有一个名为 CloudFront 的服务,可以帮助您通过 HTTPS 提供应用程序,同时它还使内容在全球范围内可用。

AWS CloudFront 是一个内容分发网络服务,在下一节中,我们将配置 AWS S3 存储桶以使用 AWS CloudFront。

配置 React 项目以使用 CloudFront

按照以下步骤配置我们的 React 项目以使用 CloudFront:

  1. 在 AWS 控制台中,选择 AWS 控制面板中的 CloudFront 服务并点击 创建分发

  2. 将托管在 AWS 上的网站的原点复制并粘贴到 原点域名 字段中:

图 16.3 – CloudFront 分发的原始配置

图 16.3 – CloudFront 分发的原始配置

  1. 接下来,配置默认的缓存行为:

图 16.4 – CloudFront 分发的查看器配置

图 16.4 – CloudFront 分发的查看器配置

  1. 一旦完成缓存配置,创建分发。AWS 将花费一些时间来创建分发,一旦完成,点击分发 ID 字段以复制 URL:

图 16.5 – CloudFront 分发的列表

图 16.5 – CloudFront 分发的列表

  1. 一旦状态变为启用,点击分布 ID字段以访问有关分布的更多详细信息并复制分布域名:

图 16.6 – 关于创建的 CloudFront 分布的详细信息

图 16.6 – 关于创建的 CloudFront 分布的详细信息

CloudFront 分布的 URL 将返回通过 HTTPS 的 React 应用程序。太好了,React 应用程序在互联网上得到了保护,并且在全球范围内得到了良好的分发。太棒了!我们已经成功使用 AWS CloudFront 在 HTTPS 上保护了我们的应用程序。从现在起,你可以使用 Django 和 React 构建全栈应用程序,通过测试和 linting 确保代码质量,使用 GitHub Actions 自动化持续集成持续交付CI/CD)管道,并使用 AWS 服务如 S3、EC2 和 CloudFront 在全球范围内部署和托管你的 Web 应用程序。

摘要

在本章中,我们介绍了一些关于优化和安全的要点。我们实现了一个登出端点来黑名单令牌,使用 Redis 添加了 Django 应用程序的缓存,优化了使用 webpack 的后端构建,并使用 AWS CloudFront 通过 HTTPS 保护了全栈应用程序。这就是这本书的最后一笔。

我们已经介绍了如何使用 Django 和 React 构建一个强大且健壮的全栈应用程序。我们已经介绍了如何从头开始创建项目,构建使用 JWT 令牌进行安全保护的 API,使用 React 和 Bootstrap 构建前端应用程序,并在 AWS 上部署应用程序。我们已经探讨了 Docker 和 GitHub Actions 等工具,以使开发和部署过程更加安全、快速和自动化。现在,你可以使用 Django 和 React 构建和部署全栈应用程序了!

我们现在到了这本书的结尾,如果你在寻找最佳实践和下一步要学习的内容,请随意直接在本章之后查看附录

问题

  1. 什么是 AWS CloudFront?

  2. 缓存失效策略有哪些?

  3. 为什么日志记录很重要?

附录

每个成功的应用程序最终都需要进行扩展,这个过程可能会引起资源问题和更多的优化问题。在这个附录中,我将列出你在阅读完这本书之后可以阅读的内容,以便你成为一个更好的全栈开发者。

日志记录

日志记录是在应用程序执行不同任务或事件时收集有关应用程序信息的行为。在应用程序的开发过程中,如果您遇到错误,可以使用print()console.log()来识别问题。更好的是,当 Django 中的DEBUG设置为true时,您可以访问500错误的整个跟踪信息。一旦您的项目部署到生产环境,这种情况就不再适用了。您可以使用 Python 提供的默认日志包在文件中实现日志记录;Django 提供了全面的支持,您可以在官方文档中探索,链接为docs.djangoproject.com/en/4.1/topics/logging/。如果您希望在出现500错误时获得实时通知,可以将您的后端连接到 Sentry、Datadog 或 Bugsnag 等服务。

数据库查询优化

Django ORM 是一个非常灵活且强大的工具,它可以被很好地使用,也可以被糟糕地使用。数据库在您的全栈应用程序中非常重要,您执行的查询越少,对 SQL 数据库的高可用性就越好。Django 提供了许多您可以学习和探索的方法,如果您需要优化数据库查询。您可以在docs.djangoproject.com/en/4.1/topics/db/optimization/中了解更多。

安全性

如果您要将 Web 应用程序部署到互联网上,确保您有一个安全的应用程序非常重要。一开始,您可能不需要很多,但您确实需要确保您的系统能够抵御 OWASP 列出的前 10 大威胁。您可以在以下链接中了解更多信息:owasp.org/www-project-top-ten/

答案

第一章

  1. 表示状态转移REST)API 是一种 Web 架构和一组约束,它提供简单的接口来与资源交互,允许客户端使用标准的 HTTP 请求检索或操作它们。

  2. Django 是一个 Python Web 框架,它能够快速开发安全且易于维护的网站。它遵循模型-视图-控制器MVC)的架构模式,并强调可重用性和可插拔性。

  3. 要创建一个 Django 项目,您需要在您的操作系统上安装 Django。一旦安装完成,您可以使用以下命令创建一个新的 Django 项目:

    django-admin startproject DjangoProject

上述命令将创建一个名为DjangoProject的 Django 项目

  1. 迁移是 Django 将您对模型所做的更改(添加字段、删除模型等)同步到数据库中的方式。

  2. Python 中的虚拟环境是一个工具,通过为不同的项目创建隔离的 Python 虚拟环境来将它们所需的依赖项保存在不同的位置。这在处理不同项目时非常有用,并且当您想要避免冲突的依赖项时也很有用。

第二章

  1. JSON Web TokenJWT)是一个用于在双方之间表示声明的 JSON 对象。JWT 常用于在 REST API 中验证用户。

  2. Django Rest FrameworkDRF)是 Django 的一个第三方包,它使得使用 Django 框架编写 RESTful API 变得简单,包括构建、测试、调试和维护。

  3. Django 模型是一个 Python 类,它代表一个数据库表,并定义了存储数据的字段和行为。

  4. DRF 中的序列化器用于将复杂的数据类型,如 Django 模型实例或 QuerySets,转换为 JSON、XML 或其他内容类型。序列化器还提供反序列化,允许解析的数据被转换回复杂类型。

  5. DRF 中的 Viewsets 是提供对基于模型资源操作的类。Viewsets 建立在 Django 的基于类的视图之上,并提供如listcreateupdatedelete等操作。

  6. DRF 路由器提供了一种简单、快速且一致的方法来将视图集连接到 URL。它允许你自动生成 API 视图的 URL 配置。

  7. 刷新令牌是由认证服务器签发的令牌,用于获取新的访问令牌。刷新令牌通过定期获取新的访问令牌来保持用户无限期地认证。

第三章

  1. 关系型数据库中一些常见的数据库关系包括:

    • 一对一:当表中的一条记录只与另一表中的一条记录相关联时,使用这种关系。

    • 一对多:当表中的一条记录与另一表中的多条记录相关联时,使用这种关系。

    • 多对多:当一张表中的多条记录与另一张表中的多条记录相关联时,使用这种关系。

  2. Django REST 权限用于控制对特定视图集的特定操作的访问。它们可以用来限制谁可以查看、添加、更改或删除 REST API 中的数据。

  3. 在 DRF 中,你可以使用LimitOffsetPagination类来分页 API 响应的结果。要使用这个类,你可以在项目的settings.py文件中包含它到REST_FRAMEWORK

  4. 要使用 Django shell,你需要打开 Django 项目的根目录下的命令行,然后运行以下命令:

    python manage.py shell

第四章

  1. 嵌套路由是一个表示两个或多个资源之间关系的 URL 端点。例如,在一个社交媒体应用程序中,你可能有一个所有帖子的路由和另一个特定帖子评论的路由。评论路由将嵌套在帖子路由中,允许你访问特定帖子的评论。

  2. drf-nested-routers是 DRF 的一个包,它允许你轻松地为你的 API 创建嵌套路由。它自动为相关资源创建适当的 URL,并允许你在其他视图内嵌套视图。

  3. ModelSerializer上的partial属性可以帮助你确定用户是否在 HTTP 请求中提交了资源的所有字段,用于修改操作,如PUTPATCHDELETE

第五章

  1. 测试是一个验证系统或软件是否按预期行为的过程。测试可以是手动或自动进行的。

  2. 单元测试是一种验证小型和独立代码片段功能性的测试,通常是一个单独的函数或方法。

  3. 测试金字塔是一个描述软件项目中不同类型测试之间平衡的概念。它建议大多数测试应该是单元测试,这些测试快速且独立,然后是较少的集成测试,这些测试检查不同代码单元之间的交互,以及少量端到端测试,这些测试检查整个系统。

  4. Pytest 是一个流行的 Python 测试框架,它使得编写小型、专注的单元测试变得容易,并提供了许多有用的功能,如测试发现、测试参数化、固定值和强大的表达式断言语法。

  5. Pytest 固定值是一种提供测试所需数据或设置资源的方法。固定值使用@pytest.fixture装饰器定义,并可以作为参数传递给测试函数,使您能够编写更具有表达性和可维护性的测试。

第六章

  1. Node.js 是一个基于 Chrome 的 V8 JavaScript 引擎构建的 JavaScript 运行时。它允许开发者将 JavaScript 运行在服务器端,以构建快速和可扩展的网络应用程序。Yarn 是一个 Node.js 的包管理器,类似于 npm,但它更快、更安全,并在不同环境中提供更一致的经验。

  2. 前端开发是构建软件应用程序用户界面的过程。在 Web 开发中,它涉及使用HTMLCSSJavaScript等语言来创建网站的视觉元素、布局和功能。

  3. 要安装 Node.js,您可以从官方 Node.js 网站(nodejs.org/)下载安装程序包,然后运行它。

  4. Visual Studio CodeVS Code)是由微软开发和维护的一个免费、开源的代码编辑器。它因其对多种语言的支持、调试和集成 Git 控制而成为开发者的热门选择。

  5. 在 VS Code 中,您可以通过点击编辑器侧边的活动栏中的扩展图标,或者通过按Ctrl + Shift + X(在 macOS 上为Cmd + Shift + X)来打开扩展面板来安装扩展。然后,您可以搜索并安装所需的任何扩展。

  6. 热重载是一个允许您立即在浏览器中看到对代码所做的更改的功能,而无需手动刷新页面。这使得开发更快、更高效,因为您可以在实时中看到更改的效果。

  7. 要使用 create-react-app 创建一个 React 应用程序,你首先需要在你的操作系统上安装 Node.js 和 yarn。然后,你可以在终端中运行以下命令来使用 yarn 创建一个新的 React 应用程序:

    yarn create react-app my-app

第七章

  1. localStorage 是由网络浏览器提供的 API,允许开发者存储数据,即使浏览器关闭或计算机重启,localStorage 中的数据也会持续存在。

  2. React-Router 是一个流行的 React 客户端路由库。它允许你声明性地将应用程序的组件结构映射到特定的 URL,这使得在页面之间导航和管理浏览器历史记录变得容易

  3. 要在 React 中配置受保护的路由,你可以使用 React-Router 的 <Route> 组件以及一个高阶组件HOC)或自定义 Hook,在渲染受保护组件之前检查用户是否已认证。例如:

    function ProtectedRoute({ children }) {
    
      const user = getUser();
    
      return user ? <>{children}</> : <Navigate to=»/login/» />;
    
    }
    
  4. React Hook 是一个特殊的函数,它允许你在函数组件中使用状态和其他 React 功能。Hooks 在 React 16.8 中引入,以使在函数组件中编写和管理状态逻辑变得更加容易。

  5. React Hooks 的例子包括:

    • useState:允许你在函数组件中添加状态。

    • useEffect:允许你在函数组件中运行副作用,如获取数据或订阅事件。

    • useContext:允许你从函数组件中访问上下文值。

  6. React Hooks 的两条规则是:

    • 只在顶层调用 Hooks。不要在循环、条件或嵌套函数内部调用 Hooks。

    • 只能在 React 函数组件中调用 Hooks。不要从常规 JavaScript 函数中调用 Hooks。

第八章

  1. 模态对话框是一个显示在当前页面之上的对话框/弹出窗口。模态对话框用于显示需要用户注意或输入的内容,例如表单、图片、视频或警报。

  2. 在 React 中,一个 props 对象。

  3. React 中的 children 元素是一个特殊的属性,用于在元素之间传递内容。它用于在元素内部嵌套 UI 元素,并且可以在父组件中使用 props.children 属性访问。

  4. 线框是一个简化的网页或应用的视觉表示,用于传达用户界面的布局、结构和功能。

  5. map 方法是 JavaScript 中的一个数组方法,用于遍历数组并创建一个新的数组,该数组包含对原始数组中每个元素应用函数的结果。它也可以在 JSX 中使用,以遍历数组并创建一组新的元素。

  6. SWR 对象上的 mutate 方法允许你以编程方式更新缓存中的数据,而无需等待重新验证发生。mutate 方法会在使用缓存数据的组件上触发重新渲染,更新 UI 以反映新数据。

第九章

  1. useParams钩子是 React Router 内置的钩子,允许你访问路由 URL 中传递的动态参数。它返回一个包含参数键值对的对象。

  2. 在 React 中,你可以使用路由的路径中的:语法来编写支持参数传递的路由。例如,你可以有post/:postId,其中postId是一个 URL 参数。

  3. useContext钩子是 React 内置的钩子,允许你在功能组件中访问上下文值。这可以在不通过组件树的多级传递 props 的情况下,在多个组件之间共享数据。

第十章

  1. FormData对象是一个内置的 JavaScript 对象,允许你构建并发送multipart/form-data请求。它可以用来上传文件或其他二进制数据形式,以及将FormData对象作为XMLHttpRequestfetch请求的主体,它将自动设置适当的Content-Type头。

  2. 在 Django 中,MEDIA_URL设置用于指定用户上传的媒体文件将被服务的 URL。

  3. Django 中的MEDIA_ROOT设置用于指定用户上传的媒体文件将被存储的文件系统路径。

第十一章

  1. render方法的render方法可以用来测试组件在类似真实环境中的行为和输出。

  2. Jest 是一个 JavaScript 测试框架,允许你为 JavaScript 代码(包括 React 组件)编写和运行单元测试。

  3. data-testid属性是一个特殊属性,允许你为测试目的向元素添加标识符。这个属性可以在测试中查询元素,并对它的状态或行为进行断言。

  4. 快照测试的一些缺点包括:

    • 随着组件的变化,快照可能会过时,需要手动更新。

    • 快照测试可能难以理解,因为它们通常会显示整个组件树,这可能很大且复杂。

  5. 在 React 测试套件中触发用户事件,你可以使用 React Testing Library 的fireEventuserEvent方法。

第十二章

  1. 在 Git 中,分支是一条独立的开发线,允许一个或多个开发者同时在不干扰彼此工作的情况下工作在不同的功能或错误修复上。分支还用于隔离更改,并使其易于合并回主代码库或分支。

  2. Git 是一个版本控制系统VCS),允许开发者跟踪代码随时间的变化,与他人协作,并在需要时回滚到以前的版本。GitHub 是一个基于 Web 的 Git 仓库托管服务。

  3. HTTP 主机头攻击是一种利用某些 Web 服务器处理 HTTP 主机头方式中的漏洞的 Web 应用程序攻击。HTTP 主机头用于指定用户试图访问的网站域名。通过操纵主机头,攻击者可以欺骗一个易受攻击的 Web 服务器从不同的域名提供内容,可能暴露敏感信息,或者允许攻击者代表用户执行操作。

  4. 在 Django 中,SECRET_KEY设置用于提供用于保护 Django 框架某些方面的密钥,例如会话管理、密码散列和生成加密签名。由于这是一条合理的信息,其值应使用环境变量存储。

第十三章

  1. Docker 是一个用于开发、运输和运行应用程序的平台,它使用容器化技术将应用程序及其依赖项打包成一个单一、可移植的容器,该容器可以在支持 Docker 的任何平台上运行。容器为运行应用程序提供了一个轻量级、隔离的环境,这使得它们在开发、预演和生产环境之间移动变得容易。

  2. Docker Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。它允许您使用单个docker-compose.yml文件来配置和启动构成您应用程序的多个服务(容器)。这使得管理复杂应用程序的依赖项和配置变得容易。

  3. Docker 与 Docker Compose 之间的主要区别在于,Docker 是一个用于创建、运输和运行容器的平台,而 Docker Compose 是一个用于定义和运行多容器应用程序的工具。此外,Docker Compose 依赖于 Docker 来创建和运行容器。

  4. 虚拟化是一种技术,允许您通过创建模拟物理计算机硬件的虚拟机,在单个物理机器上运行多个操作系统。每个虚拟机运行其操作系统,运行在虚拟机内的应用程序彼此隔离。容器化是一种技术,允许您将应用程序及其依赖项打包成一个单一、可移植的容器,该容器可以在任何平台上运行。容器是轻量级、隔离的环境,它们共享宿主操作系统的内核,这使得它们比虚拟机更快、更高效。

  5. 环境变量是在运行时可以传递给操作系统或应用程序的值。它允许您配置系统范围的设置或将信息传递给应用程序,而无需在源代码中硬编码。环境变量可以用于设置配置选项,例如文件的位置或密钥的值,并且可以轻松更改而无需修改应用程序的代码。

第十四章

  1. 持续集成CI)和持续部署CD)之间的区别是:

    • CI 是一种软件开发实践,开发者每天多次将代码集成到共享仓库中。每次集成都会通过自动构建和测试过程进行验证,以尽早捕捉错误。

    • CD 是 CI 的扩展,在代码通过自动构建和测试过程后,进一步自动将代码更改部署到生产环境。CD 的目标是确保代码始终处于可发布状态,并缩短代码编写和提供给最终用户之间的时间。

  2. GitHub Actions 是 GitHub 提供的一项功能,允许开发者自动化他们的软件开发工作流程,如构建、测试和部署代码。这些工作流程定义在 YAML 文件中,可以由各种事件触发,例如向分支推送、拉取请求或计划的时间。开发者可以使用 GitHub Actions 来自动化他们的 CI/CD 工作流程。

  3. CD 是一种实践,在代码通过自动构建和测试过程后,自动构建、测试和部署代码更改到不同的环境。它是 CI 的扩展,目标是确保代码更改始终处于可发布状态,以便可以随时部署到生产环境。

第十五章

  1. Amazon Simple Storage ServiceS3)是由Amazon Web ServicesAWS)提供的一种对象存储服务,允许您存储和检索大量数据。

  2. 要在 AWS 上创建一个身份和访问管理IAM)用户,您可以使用 AWS 管理控制台。以下是如何使用 AWS 管理控制台创建 IAM 用户的示例:

    1. 登录 AWS 管理控制台

    2. 打开 IAM 控制台。

    3. 导航面板中,选择用户然后选择添加用户

    4. 输入用户名并选择AWS 访问类型

    5. 选择权限

    6. 根据需要选择添加用户到组、创建组或添加现有组

    7. 选择标签

    8. 选择审查

    9. 选择创建用户

  3. 构建 React 应用的命令是react-scripts build。此命令将应用中的所有代码和资源打包成一个可用于部署到 Web 服务器的生产就绪构建。

  4. 在 Node.js 或更具体地说在 React 项目中,环境变量通常使用process.env对象检索。例如,您可以使用process.env.VARIABLE访问名为VARIABLE的环境变量的值。

第十六章

  1. Amazon CloudFront 是由 AWS 提供的内容分发网络CDN)。它允许您通过在位于不同地理位置的服务器上缓存内容,将网页、图片、视频等内容分发到全球用户。CloudFront 可以从多种来源交付内容,例如 S3 存储桶或自定义来源。

  2. Django 中缓存失效的策略有几种:

    • 站点级缓存:这使您能够缓存整个网站。

    • 模板片段缓存:这使您能够缓存网站的一些组件。例如,您可以选择只缓存页脚。

    • 视图级缓存:这使您能够缓存单个视图的输出。

    • 低级缓存:Django 提供了一个 API,您可以使用它直接与缓存进行交互。如果您想根据一系列操作产生特定的行为,这将非常有用。

  3. 记录日志非常重要,因为它允许您跟踪系统的活动,解决问题,并收集用于分析的数据。日志提供了系统发生事件的详细历史记录,包括用户操作、系统故障和性能指标等事件。这些信息可用于识别趋势、检测模式并解决问题。

posted @ 2025-09-18 14:34  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报