Django-和-HTML-单页应用构建指南-全-

Django 和 HTML 单页应用构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

通过 WebSockets 使用 HTML 简化了单页应用程序SPAs)的创建,通过避免前端渲染、提供实时响应以及通过将其移动到后端来简化逻辑。在这本书中,你不会学习如何使用 JavaScript 渲染框架,如 React、Vue 或 Angular,而是将逻辑移动到 Python。这将简化你的开发,利用 Django 提供的所有工具,给你带来实时结果。

开发者将学习最先进的 WebSockets 技术,以实现对 JavaScript 最小依赖的实时应用程序。他们还将从头开始学习如何使用 Docker 创建项目、测试它并在服务器上部署它。

你将跟随一个想要创建具有良好体验的 SPAs 的 Django 开发者的路线图。你将创建一个项目并添加 Docker、开发库、Django 通道和双向通信,然后你将使用 HTML 通过 WebSockets 创建各种真实项目,例如聊天应用或带有实时评论的博客。你将通过从使用 SSR 模型转向使用 HTML 通过 WebSockets 创建网页来现代化你的开发技术。使用 Django,你将创建具有专业实时项目的 SPAs,其中逻辑将在 Python 中。

到本书结束时,你将能够制作实时应用程序,并掌握 Django 的 WebSockets。

本书面向的对象

本书适合中级 Django 开发者和希望使用 HTML 和 Django 通道创建实时网站的 Python 网络开发者。如果你是一位寻找创建 API 的替代方案的开发者,你不想放弃将前端与后端分离的可能性,并且不想依赖 JavaScript 来制作 SPAs,那么这本书适合你。本书假定你具备 HTML 和 Python 的基本知识,以及基本的网络开发概念。

本书涵盖的内容

第一章设置虚拟环境,是我们准备 IDE 和 Docker 以便能够使用 Python 的地方。

第二章围绕 Docker 创建 Django 项目,是我们创建一个 Django 项目并使用不同的 Docker 容器进行测试。

第三章将 WebSockets 添加到 Django 中,是我们通过 Channels 将 WebSockets 服务器集成到 Django 中,然后测试它是否可以发送和接收数据。

第四章与数据库一起工作,是我们创建一个微博应用,在其中我们与数据库进行交互,执行基本操作,如查询、过滤、更新或删除。

第五章在房间中分离通信,是我们学习如何通过创建带有私密消息和群组的聊天应用来按用户或组分离通信。

第六章在后端创建 SPA,我们将集成解决您在构建 SPA 时可能遇到的典型问题的解决方案,例如根据路由集成服务器端渲染系统或水合部分。

第七章仅使用 Django 创建实时博客,帮助你利用所学知识构建一个完整的 SPA 博客。

第八章简化前端,介绍了通过集成 Stimulus 到前端来简化与客户端事件的交互。

要充分利用本书

您需要在计算机上安装最新版本的 Docker 和 Docker Compose(或包含所有内容的 Docker Desktop)以及代码编辑器或 IDE。我推荐 PyCharm Professional。所有代码示例都已使用 Docker 在 macOS 和 Linux 操作系统上测试;然而,它们也应该在没有问题的情况下在 Windows 上运行。

您需要在计算机上安装 Docker 和 Docker Compose 的专业版(或者包含所有内容的 Docker Desktop)。否则,您可以从终端或使用 Docker Desktop 启动 Docker。

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

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

使用的约定

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

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“前端的信息传递给receive_json,然后通过执行self.send_uppercase(data)函数接收'text in capital letters'动作。”

代码块设置如下:

* {
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    box-sizing: border-box;
}

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“目前,编辑删除按钮仅用于装饰;稍后,我们将赋予它们功能。”

小贴士或重要提示

看起来像这样。

联系我们

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

customercare@packtpub.com 并在邮件主题中提及书名。

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

copyright@packt.com 并附上材料的链接。

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

分享您的想法

一旦您阅读了《Real-Time Django over the Wire》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

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

第一部分:Python 入门

在本部分,我们将学习如何使用 Docker 容器配置和构建 Django 项目。我们从准备一个工作区、配置 Python 镜像、创建 Django 项目并将其集成,使其与操作系统隔离,并可以从任何计算机启动开始。

在本部分,我们涵盖了以下章节:

  • 第一章,设置虚拟环境

  • 第二章围绕 Docker 创建 Django 项目

第一章:设置虚拟环境

一个好的程序员不怕技术,因为他们的信心不在于编程语言,而在于他们自己的技能和经验。工具只会让他们更有效率。没有合适的软件,我们甚至无法在可接受的时间内构建一个最简单的网站。使用 Python 构建网站在任何现代操作系统上都是可能的,无论其背后的硬件如何。维护这个出色语言的核心团队已经负责了一些比较繁琐的任务,例如编译它并针对您所使用的处理器进行优化。

然而,即使在 Python 中构建一个简单的基于文本的 Web 应用程序,也需要大量的知识,包括服务器和 Web 应用程序的知识,以及 WSGI 或 ASGI 接口。我们需要抽象出这种复杂性来响应请求、环境、异步性、WebSocket、数据库连接以及其他定义当前 Web 应用程序的元素。这就是为什么我们将设置一个桌面,其中包含您成为高效现代 Django 开发者所需的一切。我们将使用 Channels 提供的技术构建不同的实时应用程序,Channels 是 Django 的一个扩展(由同一个 Django 团队开发),它包括 WebSocket 服务器和 WebSocket 集成。应用程序的架构将与服务器端渲染不同。服务器和客户端之间的通信路径将是双向的,允许我们用它来接收或发送事件和/或 HTML。我的意图是,在完成本章后,您的关注点将集中在代码上,而不是可能分散您注意力的复杂配置。为了实现这一点,我们将利用 Docker,这个著名的容器管理器,它将打开添加各种预先配置好的软件的可能性,以便快速启动,而无需投入大量时间:数据库、Web 服务器、邮件服务器和缓存等。如果您没有 Docker 的经验,请不要担心。我将向您介绍基础知识,而不会涉及底层细节。经过几次调整后,您几乎会忘记它在后台运行。

不仅要我们知道如何编写 Python 并使用 Django 创建实时基础设施,而且我们还需要具备在部署或团队工作中独立于操作系统的技能。通过虚拟化(或隔离)进程,我们可以不必关心它运行的操作系统,使项目对任何专家都容易继续,并且我们可以预测在部署到生产服务器时可能出现的未来问题。

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

  • 探索所需的软件

  • 添加依赖项

  • 配置 IDE

  • 安装 Django

  • 创建我们的项目

探索所需的软件

在本节中,我们将查看本书中将要使用的软件以及如何安装它。本章的代码可以在github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-1找到。

操作系统

你应该在支持 Docker 的操作系统上工作,以下是一些选择:

  • Linux 发行版,最好是UbuntuDebian

  • 最新的 macOS 版本

  • Windows 10 或更高版本,最好有激活的 Linux 子系统并安装了 Ubuntu 或 Debian

  • BSD 后裔,最好是 FreeBSD

代码编辑器

我假设如果你在阅读这本书,你已经有了 Python 的经验,并且你有一个 IDE 或功能丰富的编辑器已经准备好了。如果你需要更换 IDE,我在以下列表中从最推荐到最不推荐地列出了一些我认为非常适合与 Python 一起使用的 IDE:

  • PyCharm Professional:如果你是认可学校的学生,你可以从 JetBrains 那里申请免费的学生许可证。否则,我鼓励你购买许可证或使用他们的演示版。IDE 有一个免费版本,PyCharm Community Edition,但你将无法使用 Docker 解释器,因为这是专业版的功能。你可以从www.jetbrains.com/pycharm/下载此编辑器。

  • Visual Studio CodeVSCode):这是一个在 Web 开发中非常受欢迎的编辑器,由微软创建和维护。你可以从code.visualstudio.com/下载此编辑器。

  • Emacs:如果你使用的是预配置的框架,如 Spacemacs 或 Doom,它非常容易使用。你可以从www.gnu.org/software/emacs/下载此编辑器。

  • Sublime Text搭配Djaneiro插件:如果你不希望遇到复杂的情况,这是最简单的选择。你可以从www.sublimetext.com/下载此编辑器。

不要强迫自己改变。代码编辑器是一个非常个人化的东西,就像选择内衣品牌一样:一旦找到适合你生活方式的,你就不会想改变。我理解你可能也不愿意学习新的快捷键或工作流程。否则,如果你没有偏好,你可以自由地访问任何前面提到的编辑器的网站,下载并安装到你的电脑上。

书中的所有示例、活动和代码片段都将与你的编辑器或 IDE 选择兼容。它们将主要帮助你处理语法错误、自动完成和提示,但你的代码将是自包含的,因为它们总是以纯文本形式存储。Python 程序员在任何编辑器中都是 Python 程序员,但并非所有编辑器都与 Python 配合得很好。

Python

你不需要安装它。你读得对;编辑器在审阅时没有出错。我们将使用 Docker 来安装一个能够启动 Django 基本命令的 Python 容器,例如创建项目或应用或启动开发服务器。

我假设如果你在这里,那是因为你感觉使用 Python 编程很舒服。如果不是,我建议你阅读一些 Packt 的书籍:

  • 《Python 编程学习 - 第三版》,作者:Fabrizio Romano 和 Heinrich Kruger,出版社:Packt Publishing (bit.ly/3yikXfg)

  • 《专家 Python 编程 - 第四版》,作者:Michał Jaworski 和 Tarek Ziadé,出版社:Packt Publishing (bit.ly/3pUi9kZ)

Docker

安装 Docker 最快的方式是通过 Docker Desktop。它在 Windows、macOS 和 Linux(在我撰写此内容时处于测试版)上可用。只需访问官方网站,下载并安装:

www.docker.com/get-started

如果你想要直接通过终端安装它,你需要搜索 Docker Engine (docs.docker.com/engine/)。如果你使用 Linux 或 BSD,这强烈推荐。

还要安装 Docker Compose,这将简化镜像和服务声明的管理:

docs.docker.com/compose/install/

Git

没有开发是不涉及版本控制系统的。Git 是最受欢迎的选择,几乎是必学的。

如果你对此没有知识或相对基本的经验,我建议查看 Packt 的另一本书,例如 《Git 基础 - 第二版》,作者:Ferdinando Santacroce,出版社:Packt Publishing (bit.ly/3rYVvKL)。

或者,你也可以选择查看官方 Git 网站的更详细文档:

git-scm.com/

浏览器

我们将避免关注浏览器的视觉方面,这意味着前端实现特性,如 CSS 兼容性或 JavaScript 功能并不重要。最重要的是在调试后端时感到舒适。大多数时候,我们将在控制台检查请求(GETPOST 等)是否按预期工作,观察 WebSocket 通信以使其流畅,并偶尔操作渲染的 HTML。

WebSocket

WebSocket 是一种双向通信协议,与 HTTP 不同,它便于在服务器和客户端之间实时发送数据,在我们的例子中,是在 Django 服务器和前端客户端之间。

在这本书中,我将使用Firefox 开发者版(www.mozilla.org/en-US/firefox/developer/)浏览器,因为它非常方便地管理使用它时提到的各个方面。你可以自由地使用任何其他浏览器,例如ChromeSafariEdge,但我不确定我将会使用的所有功能是否都可用。

软件安装完毕后,我们可以开始使用 Python 和 Docker 的准备工作来运行 Django 或未来的 Python 代码。

添加依赖项

我们将通过 Docker 和配置文件运行 Python。这样,任何开发者都可以复制我们的代码,无论他们是否在自己的机器上安装了 Python,他们只需一个命令就可以运行相关服务。

首先,我们将创建一个名为hello.py的 Python 文件,其内容如下:

print(“Wow, I have run in a Docker container!”)

代码已经准备好了。我们可以继续。

目标将是配置 Docker 以运行该文件。听起来很简单,不是吗?我们开始吧!

我们将创建一个名为Dockerfile的文件,其代码如下:

# Image
FROM python:3.10
# Display the Python output through the terminal
ENV PYTHONUNBUFFERED: 1
# Set work directory
WORKDIR /usr/src/app
# Add Python dependencies
## Update pip
RUN pip install --upgrade pip
## Copy requirements
COPY requirements.txt ./requirements.txt
## Install requirements
RUN pip3 install -r requirements.txt

此文件用于创建一个 Docker 镜像,或模板,其中包含将被缓存的指令。由于它们是预缓存的,它们的启动几乎是瞬间的。让我们来看看代码中发生了什么:

  • 使用FROM python:3.10,我们正在使用另一个现有的镜像作为基础。我们正在扩展已经完成的工作。但是...它在哪?Docker 有一个名为python的图像存储库,或模板库,我们用标签标记它以使用 3.10 版本。如果你之前使用过 Docker,你可能想知道为什么我们不使用Alpine版本,这个在全世界服务器上节省了大量空间的著名操作系统。有两个原因:Python 运行速度较慢(pythonspeed.com/articles/alpine-docker-python/),并且它没有编译依赖项的能力。Slim版本也加剧了最后一个问题,并且仅推荐用于空间不足的生产版本。

  • ENV PYTHONUNBUFFERED: 1会显示 Python 消息,例如,当我们使用print()时。如果没有添加,它们会直接进入 Docker 日志。

  • 通过添加WORKDIR /usr/src/app,我们定义了在 Docker 容器内部执行命令的路径,而不是在我们的操作系统内部。这相当于使用cd更改目录。

  • 我们也将利用这个机会,在requirements.txt文件中安装我们未来将要添加的 Python 依赖。我们通过RUN pip install --upgrade pip更新pip,使用COPY requirements.txt ./requirements. Txt将依赖列表从文件夹复制到镜像中,最后,通过RUN pip3 install -r requirements.txt运行pip来安装所有内容。

  • 在项目的根目录下,我们创建另一个名为docker-compose.yaml的文件,其内容如下:

    version: '3.8'
    
    services:
    
      python:
        build:
          context: ./
          dockerfile: ./Dockerfile
        entrypoint: python3 hello.py
        volumes:
          - .:/usr/src/app/
    

这是编排器,一个文件,我们在这里定义每个服务和其配置。在这种情况下,我们只将有一个名为 python 的服务。通过 build,我们告诉 Docker 使用我们在 Dockerfile 中定义的镜像。通过 entrypoint,我们指示服务启动时应该做什么:python3 hello.py。最后,在 volumes 中,我们告诉它将根目录(由一个点表示)挂载到 /usr/src/app/,这是镜像的一个内部目录。这样,服务将能够访问文件夹中的所有文件。

  • 接下来,我们创建一个名为 requirements.txt 的空文件。尽管文件必须存在,但我们不会添加任何行。

我们准备出发了!打开终端,转到工作文件夹,并告诉 docker-compose 启动服务:

cd [your folder]
docker-compose up

Docker 将逐步执行几个任务:它将下载基本的 python 镜像,通过执行我们定义的指令构建自己的镜像,并启动 python 服务。它将在控制台打印 2 行,如下所示:

python_1 | Wow, I have run in a Docker container!
python_1 exited with code 0

我们已经执行了 Python 文件!胜利!

随着最近使用 Docker 运行 Python 的能力,现在是时候将其集成到 IDE 中,以便更容易地运行而无需使用终端。

配置 IDE

PyCharm 非常受欢迎,因为它是一款专门为与 Python 一起工作而准备的工具,它还包含与数据库、Git、HTTP 客户端、环境等有趣的集成。最常用的之一肯定是与 Docker 相关的,因此我将在未来的示例中使用这个出色的 IDE。然而,正如我之前所说的,使用它不是强制性的;有足够的替代方案来满足每个人。本章中显示的所有代码和活动都将独立于编辑器工作。

要设置 IDE,请按照以下步骤操作:

  1. 使用 PyCharm(文件 | 打开)打开您想要工作的文件夹。目录树将在左侧显示。

  2. 点击 Python 文件 (hello.py)。如果不使用终端,则无法运行 Python 代码;PyCharm 否则不知道 Python 解释器或可执行文件在哪里。它位于操作系统目前无法访问的 Docker 镜像中。

图 1.1 – 打开 Python 文件

图 1.1 – 打开 Python 文件

  1. PyCharm 可能会打开一个弹出窗口,建议创建虚拟环境。您可以跳过此步骤或关闭窗口;我们将使用 Docker 来完成这项工作。如果您没有看到窗口,您可以继续而无需担心。

  2. 我们接下来检查是否已安装 Docker 插件。转到 文件 | 设置 | 插件 并查找 Docker

  3. 如果已安装,它将出现在 已安装 选项卡中。如果没有,您将不得不在 市场 中查找它,然后点击 安装 按钮。然后,重新启动 PyCharm。请确保这样做。否则,我们无法继续。

图 1.2 – 安装 Docker 插件

图 1.2 – 安装 Docker 插件

  1. 现在打开文件 | 设置 | 构建、执行、部署 | Docker,然后按下+按钮。接着,搜索Docker

图 1.3 – 通过 Docker 连接

图 1.3 – 通过 Docker 连接

  1. 名称字段中输入Docker,例如,并激活Unix 套接字。在底部,你会看到连接成功的消息。

  2. 我们只需要告诉 PyCharm 不要在机器上(如果有)查找 Python 解释器或可执行文件,而是使用我们创建的 Docker 服务。

  3. 前往文件 | 设置 | 项目:可执行 Python。在这里,我们部署Python 解释器,选择远程 Python xxx Docker Compose,然后点击应用。解释器名称可能会根据文件夹名称而改变。

图 1.4 – 添加 Python 解释器

图 1.4 – 添加 Python 解释器

  1. 它将自动检测机器上已安装的依赖项,但我们将忽略它们。顺便说一句,这是一个管理所有 Python 依赖项的好地方。

  2. 现在,是时候使用你刚刚创建的配置运行 Python 代码了。

  3. 关闭hello.py然后运行‘hello’

图 1.5 – 使用 PyCharm 运行 Python

图 1.5 – 使用 PyCharm 运行 Python

  1. 在编辑器的底部,将打开一个包含执行日志的区域。作为成功完成的证明,我们可以看到print语句。

图 1.6 – 通过 Docker 集成查看 Python 执行日志

图 1.6 – 通过 Docker 集成查看 Python 执行日志

  1. 此外,如果我们打开docker-compose.yaml文件,我们可以单独运行容器。

图 1.7 – 通过 Docker 集成启动容器

图 1.7 – 通过 Docker 集成启动容器

  1. 图 1.7的第 5 行,你可以看到一个绿色的箭头;当你点击它时,它将启动服务,再次运行 Python 代码。

PyCharm 已经集成了 Docker,并且能够以隔离操作系统的方式启动带有其依赖项的 Python。我们现在可以直接使用 Django 了。我们将使用官方 Django 客户端创建一个简单的项目,以便在开发时拥有最小化的结构。

安装 Django

我们已经有了与 Python 一起工作的基础;现在,是时候安装 Django 中实际需要的最小依赖项和工具了。

我们将把以下内容添加到当前为空的requirements.txt文件中:

# Django
django===4.0
# Django Server
daphne===3.0.2
asgiref===3.4.1
# Manipulate images
Pillow===8.2.0
# Kit utilities
django-extensions===3.1.3
# PostgreSQL driver
psycopg2===2.9.1
# Django Channels
channels===3.0.4
# Redis Layer
channels_redis===3.2.0

你可能不知道其中的一些,因为它们是添加 WebSocket 到 Django 的项目的一部分。让我们逐一审查:

  • Django:这个框架自动化了许多重要任务,例如数据库连接、迁移、HTML 渲染、会话和表单。此外,作为最常用和最活跃的框架之一,它为我们提供了高度的安全性。

  • Daphne: Django 团队自己维护的异步服务器。我们需要它来处理 WebSocket,以便在不阻塞应用程序的情况下发送或接收数据。

  • asgiref: 一个需要 Channels 来工作的 ASGI 库。

  • Pillow: Django 必须的库,用于处理图像。

  • django-extensions: 一组扩展,添加了诸如 jobs、脚本执行、数据库同步以及 S3 中的静态存储等元素。

  • Psycopg2: 连接到 PostgreSQL 数据库的驱动程序,这是我们将会使用并且最推荐与 Django 一起使用的数据库。

  • Channels: 为 Django 的核心添加了处理 WebSocket 的协议和功能。

  • channels_redis: 我们必须记录我们活跃的连接以及它们所属的组。使用写入硬盘的数据库来管理它是一种低效的方式。为了解决这个问题,我们将在稍后连接到一个 Redis 服务,因为它在易失性内存上工作,并且速度极快。

PyCharm 可能会建议你安装一个插件,如下面的截图所示:

![Figure 1.8 – PyCharm 询问是否要安装新依赖项]

![Figure 1.8 – 图像 1.8]

Figure 1.8 – PyCharm 询问是否要安装新依赖项

如果你点击 安装插件,它将显示一个窗口,如下所示:

![Figure 1.9 – 图像 1.9]

![Figure 1.9 – 图像 1.9]

Figure 1.9 – PyCharm 询问是否要安装需求插件

通过点击 requirements.txt

![Figure 1.10 – 图像 1.10]

![Figure 1.10 – 图像 1.10]

Figure 1.10 – 图像 1.10

现在,我们将重新编译镜像,以便安装我们已添加的所有依赖项。

使用 PyCharm,这可以通过可视化的方式完成。转到 Dockerfile,在下面的截图所示的箭头处右键单击,并选择 为 'Dockerfile' 构建镜像

![Figure 1.11 – 图像 1.11]

![Figure 1.11 – 图像 1.11]

Figure 1.11 – 使用 PyCharm 编译 Dockerfile 镜像

如果你正在使用终端或其他编辑器,我们将在目录中使用 docker-compose

docker-compose build

通过重新创建镜像,我们在镜像中集成了所有依赖项;现在,Django 拥有我们所需的一切。为了检查它是否已安装并且我们有版本 4,我们将暂时修改 entrypoint

Entrypoint: django-admin --version

然后,我们将运行服务。

记住,你可以通过点击 Python 旁边的绿色箭头(Figure 1.12 中的第 5 行)或通过 docker-compose 来做这件事。

docker-compose up

![Figure 1.12 – 检查已安装的 Django 版本]

![Figure 1.12 – 图像 1.12]

Figure 1.12 – 检查已安装的 Django 版本

在这两种情况下,你可以看到它返回 4.0requirements.txt 中指定的版本。我们已经准备好了!

所有这些工作都可以作为未来 Python 开发的模板。不要丢失它!

通过 Django 客户端创建最小模板后,我们将配置它,以便每次服务启动时都启动测试服务器。

创建我们的项目

Django 需要自己的目录和文件结构才能工作。这就是为什么我们需要通过 django-admin 生成项目,这是一个用于启动 Django 任务的终端客户端。别担心!您不需要安装任何新东西;它是在我们添加 Django 依赖项时添加的。

让我们创建一个包含 shell 指令的文件,一次性执行所有任务。我们创建一个名为 start-project.sh 的文件,其中包含以下内容:

# Create the 'hello-word' project
django-admin startproject hello_world 
# Create a folder to host the future App with the name 
    'simple-app'.
mkdir -p app/simple_app
# Create the 'simple-app' App
django-admin startapp simple_app app/simple_app

下面是我们正在做的事情:

  • 在第一条指令 django-admin startproject hello_world . 中,我们创建了一个名为 hello_world 的项目(startproject),并通过最后的点告诉它在我们运行它的目录中创建。

  • 当我们运行 mkdir -p app/simple_app 时,我们创建了一个名为 simple_app 的目录,它位于 app 目录内。目标是组织应用程序,将它们全部保存在同一个目录中;我们也创建了第一个应用程序将被保存的文件夹:simple_app

  • 最后,我们使用 django-admin startapp simple_app app/simple_app 创建应用程序。simple_appapp/simple_app 参数分别定义了应用程序的名称和位置,这是我们之前创建的。

  • 简而言之,我们将项目命名为 hello_world,并在其中创建一个名为 simple_app 的单个应用程序。

PyCharm 可能会建议您安装一个插件来检查语法错误;这样做是个好主意。

图 1.13 – PyCharm 建议为 shell 文件安装语法检查器

图 1.13 – PyCharm 建议为 shell 文件安装语法检查器

要执行脚本,我们再次必须临时修改 entrypoint,使用 bash start-project.sh

version: '3.8'
services:
  python:
    build:
      context: ./
      dockerfile: ./Dockerfile
        entrypoint: bash start-project.sh
    volumes:
      - .:/usr/src/app/

我们像之前学习的那样启动容器:打开 docker-compose.yaml 文件,然后在 servicespython 中的双绿色箭头或单箭头处点击。

如果您正在使用终端或其他编辑器,我们将在目录中使用 docker-compose

docker-compose up

当 Docker 完成后,新的文件和目录将出现。如果您在 PyCharm 中看不到它们,请耐心等待;有时当新文件出现时,它很难刷新。您可以等待或右键单击任何文件,然后点击 从磁盘重新加载

图 1.14 – 新生成的 Django 项目

图 1.14 – 新生成的 Django 项目

现在是时候最后一次修改 entrypoint 了。让我们启动开发服务器。现在是收获我们劳动成果的时候了。

通过添加以下内容进行修改:

python3 manage.py runserver 0.0.0.0.0:8000

如果您之前没有使用过 Django,manage.py 等同于使用 django-admin。前者的优点是它使用项目的配置,而 django-admin 更通用,您必须告诉它配置在哪里;因此,当项目存在时,使用 manage.py 更实用。

我们想要执行的操作是使用runserver启动开发服务器。0.0.0.0.0:8000参数表示我们对外开放任何发起请求的IP,最后,我们将使用端口8000来接受连接。

另一方面,为了让 Docker 将服务中的端口8000路由到外部,我们将在服务内部某处添加端口8000:8000

总体来说,它看起来会是这样:

version: '3.8'
services:
  python:
    build:
      context: ./
      dockerfile: ./Dockerfile
    entrypoint: python3 manage.py runserver 0.0.0.0:8000
    ports:
      - “8000:8000”
    volumes:
      - .:/usr/src/app/

我们再次启动服务。现在,打开您喜欢的浏览器并输入127.0.0.1:8000。您将找到 Django 欢迎网页。

![Figure 1.15 – The Django default pageFigure 1.15 – The Django default page

图 1.15 – Django 默认页面

我们做到了!Django 已经在 Docker 上运行。

作为最后的细节,如果您正在使用终端,您会发现容器永远不会停止。这是因为作为优秀服务器的 Web 服务器,它始终在运行并等待请求,直到我们告诉它停止。如果您想关闭它,请按Ctrl + C。在 PyCharm 中,您应该点击红色的停止方块。

![Figure 1.16 – 通过 PyCharm 及其集成停止 Docker 服务Figure 1.16 – 通过 PyCharm 及其集成停止 Docker 服务

Figure 1.16 – 通过 PyCharm 及其集成停止 Docker 服务

摘要

我们刚刚掌握了使用 Docker 容器配置和构建 Python 项目的技能。我们从基础知识开始,创建了一个运行 Python 脚本并安装我们在requirements.txt中声明的所有依赖项的镜像。然后,我们通过一个简单的脚本自动化创建 Django 项目,并设置了开发服务器。

另一方面,为了使容器管理更简单,我们将 IDE 集成到流程中,在我们的例子中是 PyCharm。它为我们提供了启动我们将最常使用的功能的机会:构建自定义镜像、执行容器组合(现在我们只有一个 Python 服务)、查看日志以及重启和停止容器。但不要忘记,所有这些任务都可以通过终端使用docker-compose访问。

在下一章中,我们将使用 Django 构建一个包含各种数据库、Web 服务器和其他工具的完整项目。此外,我们还将集成 Django 的配置与 Docker,以便于使用不同配置的部署。

第二章:围绕 Docker 创建 Django 项目

在上一章中,我们学习了如何使用像 Docker 这样的容器系统启动用 Python 制成的应用程序。此外,我们还创建了一个 Django 项目,始终关注 WebSockets 以便未来的实时通信。目前,我们只有一个简单的可执行文件;我们需要创建一个补充 Django 的服务架构。诸如用于存储和检索信息的数据库(以及其他诸如假邮件服务器等)等重要组件对于开发将非常有用。通过配置这些工具,我们将完成围绕 Docker 的最佳工作环境的构建,然后专注于代码。

我们还将致力于环境变量的通信和集成,以通过docker-compose.yaml配置项目的某些方面。我们将修改部署的关键元素,如激活或停用调试模式,更改域名,指示静态文件存储的路径,以及其他一些重要的特殊性,这些特殊性区分了本地开发和生产服务器。

到本章结束时,我们将拥有一个完全集成的项目,包含 Django,准备好在测试或真实服务器上部署,这将也非常便于其他团队成员上手。

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

  • 探索用于构建我们的应用程序的容器

  • 添加 Django 服务

  • 配置数据库

  • 将 Django 连接到一个 web 服务器

  • 添加一个假的 SMTP 邮件服务器

  • 测试正确运行

技术要求

本章的代码可以在github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-2找到。

探索用于构建我们的应用程序的容器

容器是隔离于操作系统的进程。Docker 允许我们修改它们,添加工具,执行脚本等,而无需离开 Docker 为我们保留的运行时的内存空间。当我们想要停止与容器的交互时,我们可以停止它,我们执行的所有操作都将不复存在。当然,如果我们需要的话,我们可以将更改保存到中。这些是 Docker 虚拟硬盘,可以连接到任何挂载在文件夹中的容器;这对于容器访问项目文件或配置非常有用。

我们将使用容器创建一个易于部署的环境,无论机器上安装的软件或 Python 版本如何。此外,我们还可以以透明的方式为每个软件选择版本。

让我们先通过添加以下服务来扩展docker-compose.yaml

Django:我们将大幅度修改 Python 服务。我们不仅会更改其名称,还会添加环境变量和执行管理任务的脚本。

  • PostgreSQL:这将是我们将使用的数据库。尽管 Django 是数据库无关的,但框架本身推荐使用它(bit.ly/3JUyfUB),因为 PostgreSQL 具有丰富的字段类型和有趣的扩展。

  • Caddy:这是一个出色的 Web 服务器。它将负责管理域名,自动续订 SSL 证书,提供静态文档,并作为反向代理来访问 Django 服务。

  • Redis:如果您还不知道,这是一个使用非常快速的关键值模式的内存数据库。我们不会直接与之通信,但在打开或关闭房间时将通过通道进行通信。另一方面,无论我们是否使用 WebSockets,集成它都是一个好主意,因为它是出色的缓存系统。

  • MailHog:这是一个简单的 SMTP 服务器,它将捕获所有发送的邮件流量,并在图形界面上显示,供用户可视化。

使用 Python 服务足以启动简单的代码,但现在我们必须有一个集成了所有配置并启动 Django 服务器的 Docker 服务。

添加 Django 服务

docker-compose.yaml 中,将整个 python 块替换为以下内容:

django:
    build:
      context: ./
      dockerfile: ./Dockerfile
    entrypoint: bash ./django-launcher.sh
    volumes:
      - .:/usr/src/app/
    environment:
      DEBUG: "True"
      ALLOWED_HOSTS: hello.localhost
      SECRET_KEY: mysecret
      DB_ENGINE: django.db.backends.postgresql
      DB_NAME: hello_db
      DB_USER: postgres
      DB_PASSWORD: postgres
      DB_HOST: postgresql
      DB_PORT: 5432
      DOMAIN: hello.localhost
      DOMAIN_URL: http://hello.localhost
      REDIS_HOST: redis
      REDIS_PORT: 6379
      DEFAULT_FROM_EMAIL: no-reply@hello.localhost
      STATIC_URL: /static/
      STATIC_ROOT: static
      MEDIA_URL: /media/
      EMAIL_HOST: mailhog
      EMAIL_USE_TLS: "False"
      EMAIL_USE_SSL: "False"
      EMAIL_PORT: 1025
      EMAIL_USER:
      EMAIL_PASSWORD:
    expose:
      - 8000
    depends_on:
      - postgresql
      - redis

让我们逐一过目每个点:

  • 使用 build,就像我们使用 python 一样,我们表示我们将生成在 Dockerfile 中定义的 Python 映像:

    build:
          context: ./
          dockerfile: ./Dockerfile
    
  • 如您所见,我们已经修改了在服务启动时将执行的命令。在这种情况下,将需要启动多个命令,因此我们将选择将其保存到一个我们将调用的 shell 文件中:

    entrypoint: bash ./django-launcher.sh
    

我们将在稍后创建 django-launcher.sh,所以现在我们将忽略它。

  • 我们将挂载并同步卷,即服务空间,与当前所在的文件夹。当前文件夹的结构如下:

    folder/project:folder/container
    

在下一个片段中,点 (.) 代表项目的位置,冒号 (:) 是分隔符,/usr/src/app/ 是项目将位于的容器的路径:

volumes:
  - .:/usr/src/app/
  • 我们定义所有我们将后来与 Django 配置集成的环境变量,以便我们可以在任何时间在本地和生产服务器之间迁移:

    environment:
      KEY: value
    
  • 我们激活开发模式:

    DEBUG: True
    
  • 我们指定允许的域名(目前我们将使用一个虚构的域名):

    ALLOWED_HOSTS: hello.localhost
    
  • 我们定义一个加密密钥:

    SECRET_KEY: mysecret
    

当在本地工作的时候,其复杂性不应该很重要。

  • 我们将 PostgreSQL 配置为我们的数据库:

    DB_ENGINE: django.db.backends.postgresql
    
  • 我们指定数据库的名称:

    DB_NAME: hello_db
    

这将在稍后使用我们尚未添加的 PostgreSQL 服务时创建。

  • 我们定义一个数据库用户:

    DB_USER: postgres
    
  • 我们为数据库添加一个密码:

    DB_PASSWORD: postgres
    
  • 我们使用未来数据库服务的名称配置数据库:

    DB_HOST: postgresql
    
  • 我们指定 PostgreSQL 端口(默认使用端口 5432):

    DB_PORT: 5432
    
  • 我们添加我们将使用的域名(不要添加协议,如 https://):

    DOMAIN: hello.localhost
    
  • 我们定义要使用的路径,它将匹配 协议域名

    DOMAIN_URL: http://hello.localhost
    
  • 我们告诉 Django Redis 的地址和端口,这是另一个我们已经设置的服务:

          REDIS_HOST: redis
          REDIS_PORT: 6379
    
  • 我们给 static 添加前缀:

    STATIC_URL: /static/
    
  • 我们创建一个文件夹,用于保存静态文件:

    STATIC_ROOT: static
    

我们将在同一项目的 static 文件夹中使用。

  • 我们定义了多媒体内容的路径:

    MEDIA_URL: /media/
    
  • 我们定义了所有伪造 SMTP 服务器的配置:

    DEFAULT_FROM_EMAIL: no-reply@hello.localhost
    EMAIL_HOST: mailhog
    EMAIL_USE_TLS: False
    EMAIL_USE_SSL: False
    EMAIL_PORT: 1025
    EMAIL_USER:
    EMAIL_PASSWORD:
    

我们告诉它使用 mailhog 服务,该服务目前还不存在,端口为 1025。在实际服务器上,它可能为 25

  • 网络服务器需要访问 Django 服务器。我们将它打开在端口 8000。有两种方式可以实现,对所有可见(ports)或仅对 Docker 子网可见(expose)。它只需要对其他服务可访问:

    expose:
      - 8000
    
  • 最后,请在启动之前等待数据库。它们目前还不存在,但很快就会出现:

    depends_on:
      - postgresql
      - redis
    

接下来,我们将创建一个脚本来控制启动 Django 服务时的操作。

创建 Django 启动器

记录每次 Django 服务启动时将执行的命令是一种良好的实践。因此,我们将在项目的根目录下创建 django-launcher.sh 文件,内容如下:

#!/bin/sh
# Collect static files
python3 manage.py collectstatic --noinput
# Apply database migrations
python3 manage.py migrate
# Start server with debug mode
python3 manage.py runserver 0.0.0.0 8000

这样,每次我们启动 Django 服务时,都会获取静态文件,启动新的迁移,并在端口 8000 上启动开发服务器。

我们编辑 hello_world/settings.py。我们将导入 os 以访问环境变量:

import os

接下来,我们修改以下行:

SECRET_KEY = os.environ.get("SECRET_KEY")
DEBUG = os.environ.get("DEBUG", "True") == "True"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS"). split(",")
DATABASES = {
    "default": {
        "ENGINE": os.environ.get("DB_ENGINE"),
        "NAME": os.environ.get("DB_NAME"),
        "USER": os.environ.get("DB_USER"),
        "PASSWORD": os.environ.get("DB_PASSWORD"),
        "HOST": os.environ.get("DB_HOST"),
        "PORT": os.environ.get("DB_PORT"),
    }
}
STATIC_ROOT = os.environ.get("STATIC_ROOT")
STATIC_URL = os.environ.get("STATIC_URL")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = os.environ.get("MEDIA_URL")
DOMAIN = os.environ.get("DOMAIN")
DOMAIN_URL = os.environ.get("DOMAIN_URL")
CSRF_TRUSTED_ORIGINS = [DOMAIN_URL]
"""EMAIL CONFIG"""
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_ADDRESS")
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS") == "True"
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = os.environ.get("EMAIL_PORT")
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
STATIC_ROOT = os.environ.get("STATIC_ROOT")
STATIC_URL = os.environ.get("STATIC_URL")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = os.environ.get("MEDIA_URL")
DOMAIN = os.environ.get("DOMAIN")
DOMAIN_URL = os.environ.get("DOMAIN_URL")
CSRF_TRUSTED_ORIGINS = [DOMAIN_URL]
"""EMAIL CONFIG"""
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_ADDRESS")
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS") == "True"
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL") == "True"
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = os.environ.get("EMAIL_PORT")
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [(os.environ.get("REDIS_HOST"), os.environ.get("REDIS_PORT"))],
        },
    },
}

这样,你将完美地集成 Django。如果你遇到问题,请随时复制在线的示例材料:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-2

没有数据库的应用程序用途非常有限。因此,我们将为 Django 提供两个数据库:PostgreSQL 和 Redis。你很快就会明白为什么。

配置数据库

我们继续在 docker-compose.yaml 中添加服务。在 Django 服务之后,我们添加以下配置:

postgresql:
    image: postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: hello_db
    volumes:
      - ./postgres_data:/var/lib/postgresql/data/
    expose:
      - 5432

image: postgres 中,我们使用官方的 PostgreSQL 镜像。它将从官方仓库自动下载。接下来,我们配置环境变量以指示用户凭据(POSTGRES_USERPOSTGRES_PASSWORD)和数据库的名称(POSTGRES_DB)。这些变量必须与 Django 服务中声明的变量匹配;否则,它将无法连接。

保留数据库副本非常重要,否则重启时所有内容都会丢失。postgres_data:/var/lib/postgresql/data/ 表示容器中所有 PostgreSQL 内容都保存在 postgres_data 文件夹中。最后,我们暴露 Django 将使用的端口(5432)。

然后,我们添加 Redis,另一个键值数据库:

redis:
    image: redis:alpine
    expose:
      - 6379

就这么简单。我们使用带有 alpine 标签的官方镜像,使其尽可能轻量,并暴露端口 6379

我们已经准备好了 Django 和数据库。下一步是将 Django 连接到一个暴露项目并自动管理 SSL 证书的 Web 服务器。

将 Django 连接到 Web 服务器

我们必须有一个管理静态内容网关服务。我们将使用 Caddy,因为它简单。

Caddy 使用一个名为 Caddyfile 的平面文件进行配置。我们必须创建它并添加以下内容:

http://hello.localhost {
    root * /usr/src/app/
    @notStatic {
      not path /static/* /media/*
    }
    reverse_proxy @notStatic django:8000
    file_server
}
http://webmail.localhost {
    reverse_proxy mailhog:8025
}

第一行,http://hello.localhost,我们指明了我们将使用的域名。由于我们处于开发环境,我们将指示 http 协议而不是 https。接下来,通过 root * /usr/src/app/file_server,我们告诉 Caddy 暴露静态文件(图像、CSS 文件、JavaScript 文件等),因为这不是 Django 的任务。最后,我们通过端口 8000 反向代理 django 服务,忽略其静态或媒体路由以避免冲突。

第二个块再次是一个反向代理,它将指向具有 webmail.localhost 域名的假 SMTP 邮件界面。

在配置就绪后,我们必须创建服务。我们将 docker-compose.yaml 添加到 Caddy 服务中:

caddy:
    image: caddy:alpine
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy_data:/data
    depends_on:
      - django

就像 Redis 一样,我们使用其 alpine 版本的官方镜像:image: caddy:alpine。我们公开打开端口 80443,以便任何访客都可以访问网站。接下来是连接两个卷:Caddyfile 配置文件与容器内部的配置文件(./Caddyfile:/etc/caddy/Caddyfile)以及 Caddy 信息与一个我们将保存在项目中的文件夹 caddy_data./caddy_data:/data)。

下一步将是添加一个邮件服务器来测试未来邮件是否被用户正确接收。此外,我们还将测试其他服务是否按预期工作。

添加一个假 SMTP 邮件服务器

docker-compose.yaml 的末尾,我们添加最后一个服务:

mailhog:
    image: mailhog/mailhog:latest
    expose:
      - 1025
      - 8025

使用的端口将是 1025,用于 Django 连接到 SMTP 服务器,以及 8025,通过 webmail.localhost 域名访问 Web 界面,因为 Caddy 将充当反向代理。

现在我们已经添加了所有容器,是时候测试容器是否运行并且可以相互工作了。

测试正确运行

最后,我们将从 docker-compose.yaml 中拉取所有服务来测试容器是否运行并且可以相互工作。

Caddy 和 Django

Caddy 和 Django 很容易检查,因为当你输入 hello.localhost 域名时,你会看到 Django 完全运行,并带有其欢迎页面:

![Figure 2.1 – Django 在 hello.localhost 域名下运行Figure 2.1_B18321.jpg

图 2.1 – Django 在 hello.localhost 域名下运行

我们知道 Django 已经连接到 PostgreSQL,因为我们可以在日志中看到它如何应用迁移:

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
...

MailHog

MailHog 很简单,因为当你输入 webmail.localhost 域名时,你会看到带有空收件箱的 Web 界面:

![Figure 2.2 – MailHog WebMail with an empty inboxFigure 2.2_B18321.jpg

图 2.2 – 拥有空收件箱的 MailHog WebMail

最后,使用 Redis,我们只需确保日志中没有错误。否则,它将保持沉默。

摘要

在上一章中,我们能够使 Python 在完全隔离操作系统的环境中运行,包括其依赖项。这并不比创建一个虚拟环境有太大的区别。但在这个版本中,我们更进一步,将所有外部软件整合进了 Django。容器已经成为了网站的核心,包括像数据库和自身网络服务器这样重要的元素。更重要的是,与 Django 的集成不仅仅是装饰性的,因为最关键的配置都源自 Docker 环境变量,这些变量直接影响到 settings.py 文件。目前,如果我们愿意的话,只需一条命令就可以在任何安装了 Docker 的服务器上部署网站。在 Docker 的每一行中,我们都能找到和谐与架构。

现在,我们已经准备好深入异步操作、WebSocket、通道和实时请求了。

第二部分:Django 中的 WebSockets

在本部分,我们将使用 Django Channels 与 WebSockets 服务器协同工作。我们将学习如何从后端实时发送消息,从纯文本、JSON 到复杂的 HTML 结构,以及如何从前端发送消息。我们还将学习如何使用数据库来监控连接,区分发送给用户组的消息或广播消息。

在本部分,我们将涵盖以下章节:

  • 第三章, 将 WebSockets 添加到 Django

  • 第四章, 与数据库协同工作

  • 第五章, 分隔房间中的通信

第三章:将 WebSocket 添加到 Django

Channels 是一个 Django 扩展,它允许我们使用除 HTTP 之外的其他协议。Django 团队了解到包含其他协议的现有局限性,因此不得不创建一个新的服务器,名为 Daphne,它与异步服务器网关接口ASGI)本机兼容,这是Web 服务器网关接口WSGI)的更新。没有 Channels,将无法使用 WebSocket 协议。

你可能会想知道为什么从 WSGI 迁移到 ASGI 如此重要。首先,我们需要了解什么是通信接口。当我们想为 Python 站点提供服务,无论是 Django 还是任何其他框架时,我们需要运行能够保持实例活跃并介助于任何请求的 Web 服务器的软件。有多个接口让 Web 服务器理解 Python,但最推荐的是 WSGI 规范,这是 Python 标准,用于 Web 服务器(Apache、Nginx、Caddy 等)与任何 Python Web 应用或框架(Django、Flask、FastAPI 等)之间的通信。不幸的是,存在一个限制。

当我们想与其他协议(如 WebSocket)一起使用 HTTP 时,这是不可能的;HTTP 并非设计为与其他协议一起工作。替代方案是使用 ASGI 规范,这将允许我们接受不同的协议和异步请求,将任务拆分为事件,并在响应后保持请求活跃。此外,它与使用 WSGI 的应用程序兼容,因此它将完美地与现有应用程序一起工作。这就是为什么 Daphne(记住它是 Django 的新 ASGI 服务器)是精神上的继承者,因为它保持了兼容性并扩展了 Django 的可能性。这也是为什么我们在创建 Docker 容器时包括了daphneasgirefchannels的依赖项,在第二章,“围绕 Docker 创建 Django 项目”中,它们是能够与 WebSocket 协议一起工作的最小工具。

在本章中,我们将学习如何激活 Channels,配置 Django 以使用 Daphne,在前后端之间创建通信通道,发送不同格式(纯文本、JSON 和 HTML),其中 Django 将生成将在前端显示的 HTML 块。我们还将深入了解其他重要的 Channels 组件,如作用域消费者路由事件

到本章结束时,我们将掌握使用 Django 在服务器和客户端之间建立双向通信所需的最基本技能。

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

  • 使用 Daphne 创建我们的第一个页面

  • 使用消费者监听事件

  • 从后端发送纯文本

  • 发送和接收 JSON 消息

  • 在后端渲染 HTML

技术要求

本章的代码可以在 github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-3 找到。

使用 Daphne 创建我们的第一个页面

让我们从激活 Channels 提供的所有实时功能开始。因此,我们将启用配置中的 Channels,更改 ASGI 的 WSGI 服务器,并构建一个最小的 HTML 页面来检查一切是否运行正确。我们将继续我们在 第二章 中开始的应用程序,即 围绕 Docker 创建 Django 项目。如果您没有代码,您可以使用以下存储库中的示例:github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-2

让我们开始:

  1. 我们将要做的第一件事是激活 Channels。为此,我们打开 hello_world/settings.py,并在 INSTALLED_APPS 下添加 app.simple_app(放在末尾)和 channels(放在最前面):

    INSTALLED_APPS = [
        ' channels', # New line
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'app.simple_app', #New line
    ]
    
  2. 正如我们在介绍中学习的,Channels 需要一个 ASGI 兼容的服务器才能工作。如果我们不满足这一要求,Django 甚至无法启动。这就是为什么我们必须指出配置 Daphne 或任何其他管理界面的服务器的配置文件所在位置。在我们的例子中,我们将前往 settings.py 的末尾并添加以下行:

    ASGI_APPLICATION = "hello_world.asgi.application"
    
  3. 由于项目位于 app 文件夹中,Django 可能无法正确处理导入。为了解决这个问题,我们将更改我们应用程序的名称。我们打开 app/simple_app/apps.py 并将其保留如下:

    from django.apps import AppConfig
    class SimpleAppConfig(AppConfig):
        default_auto_field = 'django.db.models.
            BigAutoField'.
        name = 'app.simple_app' # Update
    
  4. 接下来,我们将创建一个基本的 HTML 页面,其中 JavaScript 将与未来的 WebSocket 服务器交互。

app/simple_app/ 内部,我们创建一个名为 templates 的文件夹,并在其中创建一个名为 index.html 的新文件。完整路径为 app/simple_app/templates/index.html。我们包含以下内容:

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, 
      user-scalable=no, initial-scale=1.0, maximum- 
        scale=1.0, minimum-scale=1.0">
    <title> Index </title>
</head>
<body>
    <h1> Hello Django! <h1>
</body>
</html>
  1. 接下来,让我们创建一个视图来展示我们刚刚创建的 HTML 文件。我们打开 app/simple_app/views.py 并创建一个名为 index 的视图。在 return 中,我们将告诉它以模板的内容进行响应:

    from django.shortcuts import render
    
    def index(request):
        return render(request, 'index.html', {})
    
  2. 现在我们只需要提供一个路由。我们进入 hello_world/urls.py,导入视图并添加一个新的 path

    from django.contrib import admin
    from django.urls import path
    from app.simple_app import views
    
    urlpatterns = [
        path('', views.index, name='index'), # New line
        path('admin/', admin.site.urls),
    ]
    
  3. 我们启动 Docker 并进入 http://hello.localhost/。它将响应一个简约的消息:

![Figure 3.1 – Displaying a static HTML page on the domain hello.localhost]

![Figure 3.1_B18321.jpg]

Figure 3.1 – Displaying a static HTML page on the domain hello.localhost

恭喜!您已经使用 Daphne 和 Django 构建了一个网页,它准备好处理 WebSocket 连接。

下一步是构建并了解消费者概念,这是 Channels 处理事件的方式。我们将连接前端与一个简单的消费者,它将作为中介在 Django 和 JavaScript WebSocket 客户端之间进行通信。

使用消费者监听事件

我们将构建一个示例,在这个示例中,我们可以在后端和前端之间实时发送消息。我们需要一个中介来监听两者并做出决定。Channels 自带一个特别准备好的工具,称为消费者。它是一系列在 WebSocket 客户端触发事件时被调用的函数。

在这里,你可以看到 WebSocket 的最小消费者结构:

from channels.generic.websocket import WebsocketConsumer
class NameConsumer(WebsocketConsumer):
    def connect(self):
        """Event when client connects"""
        # Informs client of successful connection
        self.accept()
    def disconnect(self, close_code):
        """Event when client disconnects"""
        pass
    def receive(self, text_data):
        """Event when data is received"""
        pass

让我们逐一了解前述代码片段中每个事件的作用:

  • connect:请求连接权限。如果我们接受,我们将分配一个组。关于这一点,我们稍后再谈。

  • receive:向我们发送信息,我们决定如何处理它。它可能触发一些操作,或者我们可能忽略它;我们没有义务处理它。

  • disconnect:通知我们将要关闭连接。提前意识到连接将要关闭是很重要的。

作为技术性的,消费者是使用 ASGI 创建事件驱动应用程序的一种抽象。它们允许我们在发生变化时执行所需的代码。如果你曾经使用过 JavaScript 事件,你会非常熟悉它们。如果你还没有,你可以将它们视为视图的通道,除了,它们不是通过请求到我们链接到视图的 URL 来触发的,而是执行函数的动作。

Django 能够捕获客户端事件。Channels 知道是否有新的客户端已连接、断开连接或发送了信息。然而,后端有一个非常强大的能力:选择信息接收者。当客户端请求连接时,Django 必须为客户端分配一个组或通道。客户端可以单独存在,或者与其他客户端分组,这取决于需要。当发送消息的时间到来时,我们必须决定谁将接收它——一个特定的客户端或一个组。

在什么情况下我需要向客户端或一组发送数据?让我们以聊天为例。当我发送一条私信时,Django 会将新文本发送给特定的客户端,即我正在与之交谈的特定用户。另一方面,如果我与其他用户在同一个组中,当我发送消息时,后端只会将文本发送给特定的选择,即所有订阅聊天室的用户。前端不会决定是否接收新信息或接收者是谁;消费者是掌握谁移动可用信息的主导者。

现在我们已经看到了消费者如何有用,让我们看看它们在向服务器/客户端发送纯文本时扮演的角色。我们将探讨客户端如何捕获从通道接收到的信息。

从后端发送纯文本

让我们构建第一个最小化的消费者,当 WebSocket 客户端连接时,它会向客户端打招呼。稍后,我们将增加复杂性和其他操作。

示例的所有代码都可以在 github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-3/Sending%20plain%20text 找到:

  1. 我们使用以下内容创建 app/simple_app/consumers.py

    # app/simple_app/consumers.py
    from channels.generic.websocket import WebsocketConsumer
    
    class EchoConsumer(WebsocketConsumer):
    
        def connect(self):
            """Event when client connects"""
    
            # Informs client of successful connection
            self.accept()
    
            # Send message to client
            self.send(text_data="You are connected by WebSockets!")
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            pass
    
        def receive(self, text_data):
            """Event when data is received"""
            pass
    

让我们解释一下我们刚刚创建的文件中的每个元素:

  • 通过 from channels.generic.websocket import WebsocketConsumer,我们导入了用于 WebSockets 的消费者对象。

  • 我们使用 class EchoConsumer(WebsocketConsumer): 声明消费者,并将其命名为 EchoConsumer

  • 你至少需要三个函数,这些函数是前端操作将触发的事件:connectdisconnectreceive。我们将重点关注 connect

  • 当客户端连接时,我们首先会确认连接,以建立 Django 和客户端之间的未来通信。我们使用 self.accept()

  • 最后,我们将使用 self.send 发送一条消息,内容为 "You are connected by WebSockets!"。这样,每个连接的客户端都会收到一条问候。

  1. 现在我们需要为消费者分配一个路由,以便 WebSocket 客户端可以连接到。为此,我们在 ASGI 服务器上添加了连接到消费者的路由。我们打开 hello_world/asgi.py 并更新路径 /ws/echo/,指向 EchoConsumer

    # hello_world/asgi.py
    import os
    from django.core.asgi import get_asgi_application
    from channels.auth import AuthMiddlewareStack
    from channels.routing import ProtocolTypeRouter, 
        URLRouter
    from django.urls import re_path
    from app.simple_app.consumers import EchoConsumer
     os.environ.setdefault('DJANGO_SETTINGS_MODULE', 
        'hello_world.settings')
    
    application = ProtocolTypeRouter({
        # Django's ASGI application to handle traditional 
        HTTP requests
        "http": get_asgi_application(),
        # WebSocket handler
        "websocket": AuthMiddlewareStack(
            URLRouter([
                re_path(r"^ws/echo/$", EchoConsumer.
                    as_asgi()),
            ])
        ),
    })
    

asgi.py 文件中,你可以找到所有可以应用于 ASGI 服务器的配置:

  • 由于 ASGI 在 Django 之前加载,它无法知道 Django 自身配置文件的路径。我们必须使用 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hello_world.settings') 来先发制人。

  • application 中,我们配置所有路由,无论是 HTTP 还是其他协议。我们使用 ProtocolTypeRouter 对象来指示每个路由的类型和目的地。

  • 仍然需要使用传统的 HTTP 请求:通过 HTTP 加载页面、管理会话、cookies 以及其他特定功能。为此任务,我们在 ProtocolTypeRouter 中包含 "http": get_asgi_application()

  • 最后,我们使用 re_path(r'ws/echo/$', consumers.EchoConsumer.as_asgi()) 包含消费者路径。现在连接到 EchoConsumer 的路径是 /ws/echo/

  1. 接下来,我们将 WebSocket 客户端连接到 Django。

我们前往 app/simple_app/templates/index.html,我们将在这里使用 JavaScript 定义一个连接到我们刚刚创建的路径的 WebSocket 客户端:

{# app/simple_app/templates/index.html #}
<! doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, 
      user-scalable=no, initial-scale=1.0, maximum-
        scale=1.0, minimum-scale=1.0">
    < title> Index </title>
</head>
<body>
    <h1>Hello Django!</h1>

    <!-- Place where we will display the connection 
    message. -->
    <h2 id="welcome"></h2>
    <script>
        // Connect to WebSockets server (EchoConsumer)
        const myWebSocket = new WebSocket("ws://{{ 
            request.get_host }}/ws/echo/");

        // Event when a new message is received by 
        WebSockets
        myWebSocket.addEventListener("message", 
            (event) => {
            // Display the message in '#welcome'.
            document.querySelector("#welcome").
                textContent = event.data;
        });
    </script>
</body>
</html>

我们已经使用 JavaScript 创建了一个最小化的 WebSocket 客户端来监听从后端接收到的所有内容:

  • 我们定义一个 HTML 标签来显示后端将发送给我们的欢迎消息。添加 <h2 id="welcome"></h2> 就足够了;稍后,我们将用 JavaScript 填充它。

  • 我们通过 new WebSocket() 连接到 /ws/echo/。地址必须包含以下结构:protocol://domain/path。在我们的例子中,它将是 ws://hello.localhost/ws/echo/

  • 当后端发送消息时,将触发 message 事件。一旦我们连接到 Django,我们就会收到我们已编程的消息,然后在 <h2> 中显示它。

WebSocket 安全协议

我们可以使用 ws:// 协议,其中信息以纯文本形式发送,或者使用 wss:// 来保持安全连接。这种差异类似于使用 http://https://。当我们在生产环境中或者能够提供 SSL 证书时,我们将更改协议以保持安全;而在开发过程中,这并不是必需的。

在您喜欢的浏览器中打开地址 http://hello.localhost

图 3.2 – 从后端发送纯文本(“您通过 WebSockets 连接”)并在标题下方的 HTML 元素中渲染消息

图 3.2 – 从后端发送纯文本(“您通过 WebSockets 连接”)并在标题下方的 HTML 元素中渲染消息

我们刚刚学习了如何通过 WebSockets 从后端异步发送纯文本到前端。OK!这并不非常引人注目;它是在我们连接的同一时刻发送的。然而,正如我们构建的那样,我们可以随时发送消息。

让它更有趣:我们是否同步所有访客的时间?换句话说,实时向所有连接的客户端发送相同的信息。当然!

我们回到 app/simple_app/consumers.py。我们将创建一个无限循环,其中每秒向前端发送一个文本,特别是当前时间。我们将使用 threading 创建一个后台任务,不会产生任何中断:

# app/simple_app/consumers.py
from channels.generic.websocket import WebsocketConsumer
from datetime import datetime # New line
import time # New line
import threading # New line
class EchoConsumer(WebsocketConsumer):
    def connect(self):
        """Event when client connects"""
        # Informs client of successful connection
        self.accept()
        # Send message to client
        self.send(text_data="You are connected by 
            WebSockets!")
        # Send message to client every second
        def send_time(self): # New line
            while True:
                # Send message to client
                self.send(text_data=str(datetime.now(). 
                    Strftime("%H:%M:%S")))
                # Sleep for 1 second
                time.sleep(1)
        threading.Thread(target=send_time, args=(self,)).
            start() # New line
    def disconnect(self, close_code):
        """Event when client disconnects"""
        pass
    def receive(self, text_data):
        """Event when data is received"""
        pass

现在,在不同的标签页或浏览器中打开相同的地址 hello.localhost;您将看到它们显示的确切相同的时间。所有客户端都是同步的,接收相同的信息。无需等待,无需询问后端。

图 3.3 – 实时向所有访客显示相同的时间

图 3.3 – 实时向所有访客显示相同的时间

眼前的实时力量。也许它正在激发你的想象力;可能性是无限的:一个选举系统?一个拍卖网站?通知?出租车定位器?食品订单?聊天?我们将通过小型项目继续探索。

在接下来的章节中,我们将继续学习如何以不同的格式从后端发送消息,例如 JSON 或 HTML。

从后端发送 JSON

我们将从后端以 JSON 格式发送内容,并在前端消费它。此外,我们将为代码提供一个可重用的结构,这在整本书中都将是有用的。

示例的所有代码都可以在 github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-3/Sending%20JSON 找到。

我们有一个名为 JsonWebsocketConsumer 的消费者类型,适用于发送或接收 JSON。它与 WebsocketConsumer 相同,除了两点不同:

  • 我们需要添加 send_json 函数来编码成 JSON:

    book = {
        'title': 'Don Quixote',
        author': 'Miguel de Cervantes'.
    }
    self.send_json(content=book)
    
  • 我们有一个新的事件,称为 receive_json,当从客户端接收到消息时会自动解码 JSON:

    def receive_json(self, data):
        """Event when data is received"""
        pass
    

为了说明我们如何以 JSON 格式发送内容,我们将创建一个宾果项目。

示例项目 – 生成随机数字的票据

当客户端连接时,将生成一个包含一系列随机数字的票据,并通过 WebSockets 交付给他们。然后,我们不时地发送一个 Django 随机球。当玩家拥有所有数字时,我们将显示获胜信息。

让我们开始吧:

  1. 我们在 app/simple_app/consumers.py 中添加了一个新的消费者,它扩展了 JsonWebsocketConsumer

    from channels.generic.websocket import JsonWebsocketConsumer
    class BingoConsumer(JsonWebsocketConsumer):
    
        def connect(self):
            self.accept()
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            pass
    
        def receive_json(self, data):
            """Event when data is received"""
            Pass
    
  2. 我们生成一个包含 1 到 10 之间五个数字的票据。我们将使用 set() 来避免重复:

    class BingoConsumer(JsonWebsocketConsumer):
    
        def connect(self):
            self.accept()
            ## Send numbers to client
            # Generates numbers 5 random numbers,  
               approximately, between 1 and 10
            random_numbers = list(set([randint(1, 10) for 
                _ in range(5)])))
            message = {
                ' action': 'New ticket',
                ' ticket': random_numbers
            }
            self.send_json(content=message)
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            pass
    
        def receive_json(self, data):
            """Event when data is received"""
            pass
    

为了通知客户端我们将执行哪种操作,我们在发送的 JSON 中包含 action。另外,在 ticket 中包含数字列表。

  1. 编辑 hello_world/asgi.py 并添加指向 BingoConsumer 的路径 /ws/bingo/。别忘了导入它。现在我们有一个新的端点来为未来的 WebSockets 客户端提供数据。是时候创建 HTML 了:

    # hello_world/asgi.py
    import os 
    from django.core.asgi import get_asgi_application
    from channels.auth import AuthMiddlewareStack
    from channels.routing import ProtocolTypeRouter, URLRouter
    from django.urls import re_path
    from app.simple_app.consumers import EchoConsumer, BingoConsumer # Update
    
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 
        'hello_world.settings')
    
    application = ProtocolTypeRouter({
        # Django's ASGI application to handle traditional 
        HTTP requests
        "http": get_asgi_application(),
        # WebSocket handler
        "websocket": AuthMiddlewareStack(
            URLRouter([
                re_path(r"^ws/echo/$", EchoConsumer.
                    as_asgi()),
                re_path(r"^ws/bingo/$", BingoConsumer.
                    as_asgi()), # New line
            ])
        ),
    })
    

消费者准备向每个连接的客户端发送带有随机数字的票据。下一步将是准备前端以接收它并在适当的 HTML 元素中渲染它。

前端接收 JSON

目标是接收来自后端的异步 JSON,JavaScript 将在事件中检测到它。有了这些数据,我们将在 HTML 中向访客展示信息:

  1. 我们在 app/simple_app/templates/bingo.html 中创建一个新的 HTML 文件,它将包含所有前端:

    {# app/simple_app/templates/bingo.html #}
    <! doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user
                scalable=no, initial-scale=1.0, maximum-
                  scale=1.0, minimum-scale=1.0">
        <title>Bingo</title>
    </head>
    <body>
        <h1>Bingo</h1>
        <h2>Ball: <span id="ball"></span></h2>.
        <h2 id="ticket"></h2>
    
        <script>
            // Connect to WebSockets server
            (BingoConsumer)
            const myWebSocket = new WebSocket("ws://{{ 
                request.get_host }}/ws/bingo/");
            let ticket = [];
    
            // Event when a new message is received by 
               WebSockets
            myWebSocket.addEventListener("message", 
                (event) => {
                const myData = JSON.parse(event.data);
                switch (myData.action) {
                    case "New ticket":
                        // Save the new ticket
                        ticket = myData.ticket;
                        // Render ticket
                        document.getElementById("ticket"). 
                             textContent = "Ticket: " + 
                                 ticket.join(", ");
                        break;
                    } }
            });
        </script>
    </body>
    </html>
    
  2. 我们将需要一个视图来为我们的模板创建视图。我们在 app/simple_app/views.py 中添加了以下函数:

    from django.shortcuts import render
    
    def index(request):
        return render(request, 'index.html', {})
    
    def bingo(request): # New function
        return render(request, 'bingo.html', {})
    
  3. hello_world/urls.py 中,我们包括 /bingo/ 路径:

    from django.contrib import admin
    from django.urls import path
    from app.simple_app import views
    
    urlpatterns = [
        path('', views.index, name='index'),
        path('bingo/', views.bingo, name='bingo'), # New line
        path('admin/', admin.site.urls),
    ]
    

通过这个更改,票据生成就准备好了。

  1. 当我们输入 http://hello.localhost/bingo/ 时,我们将看到一个随机数字的票据,它只会给我们:

图 3.4 – 通过 WebSockets 连接时后端返回一组随机数字

图 3.4 – 通过 WebSockets 连接时后端返回一组随机数字

目前,消费者返回如下 JSON 给任何连接到 /ws/bingo/ 的客户端:

{
" action": " New ticket "
" ticket": [1, 2, 3...] // Random numbers
}

JavaScript 等待,监听。如果它接收到一个 "action""New ticket" 的 JSON,它将把 "ticket" 的全部内容存储在 ticket 变量中。最后,JSON 显示 HTML:

myWebSocket.addEventListener("message", (event) => {
const myData = JSON.parse(event.data);
switch (myData.action) {
case "New ticket":
// Save the new ticket
ticket = myData.ticket;
// Render ticket
document.getElementById("ticket"). textContent = 
    "Ticket: " + ticket.join(", ");
                    break;
} 
}
});

我们现在为每位客户都有一套自动生成的数字来玩。下一步将是向所有客户发送相同的随机数字来代表球。

示例项目 – 检查匹配的数字

下一个里程碑是定期从后端发送随机数字,供前端检查匹配。是时候混合球了!

  1. 我们创建一个线程生成随机数字,并每秒发送一次。我们将调用动作'New ball'

    # app/simple_app/consumers.py
    from channels.generic.websocket import WebsocketConsumer
    from datetime import datetime
    import time
    import threading
    from random import randint
    from channels.generic.websocket import JsonWebsocketConsumer
    
    class EchoConsumer(WebsocketConsumer):
     # Echo Code
    class BingoConsumer(JsonWebsocketConsumer):
    
        def connect(self):
            self.accept()
            ## Send numbers to client
            # Generates numbers 5 random numbers, approximately, between 1 and 10
            random_numbers = list(set([randint(1, 10) for 
                _ in range(5)]))
            message = {
                ' action': 'New ticket',
                ' ticket': random_numbers
            }
            self.send_json(content=message)
    
            ## Send balls
            def send_ball(self):
                while True:
                    # Send message to client
                    random_ball = randint(1, 10)
                    message = {
                        ' action': 'New ball',
                        ' ball': random_ball
                    }
                    self.send_json(content=message)
                    # Sleep for 1 second
                    time.sleep(1)
    
            threading.Thread(target=send_ball, 
                args=(self,)). start()
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            pass
    
        def receive_json(self, data):
            """Event when data is received"""
            Pass
    
  2. 在监听 Django 的 JavaScript 事件中,我们将添加一个案例来检测是否到达了带有"New ball"的动作:

    {# app/simple_app/templates/bingo.html #}
    <! doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-
                scalable=no, initial-scale=1.0, maximum-
                  scale=1.0, minimum-scale=1.0">
        <title>Bingo</title>
    </head>
    < body>
        <h1>Bingo</h1>
        <h2>Ball: <span id="ball"></span></h2>.
        <h2 id="ticket"></h2>
    
        <script>
            // Connect to WebSockets server (BingoConsumer)
            const myWebSocket = new WebSocket("ws://{{ 
                request.get_host }}/ws/bingo/");
            let ticket = [];
    
            // Event when a new message is received by 
            WebSockets
            myWebSocket.addEventListener("message", 
               (event) => {
                const myData = JSON.parse(event.data);
                switch (myData.action) {
                    case "New ticket":
                        // Save the new ticket
                        ticket = myData.ticket;
                        // Render ticket
                        document.getElementById("ticket"). 
                            textContent = "Ticket: " + 
                                ticket.join(", ");
                        break;
                    case "New ball":
                        // Get the ball number
                        ball = myData.ball;
                        // Check if ball is in the ticket 
                        and remove it
                        ticket = ticket.map(item => item 
                            === ball ? "X" : item);
                        // Render ticket
                        document.getElementById("ticket"). 
                            textContent = "Ticket: " + 
                                ticket.join(", ");
                        // Render ball
                        document.getElementById("ball"). 
                            textContent = ball;
                        // Check if we have a winner
                        if (ticket.find(number => number 
                            !== "X") === undefined) {
                            // We have a winner
                            document.getElementById
                                ("ticket"). textContent = 
                                    "Winner!";
                        }
                        break;
                }
            });
    
        </script>
    </body>
    </html>
    
  3. 如果是这样,我们将在票据数组中搜索匹配项,并用X替换数字:

![图 3.5 – 如果任何球与票据上的数字匹配,我们将用 X 替换它图片

图 3.5 – 如果任何球与票据上的数字匹配,我们将用 X 替换它

那我们怎么知道我们赢了?如果我的票据上的所有内容都是X:游戏结束!

![图 3.6 – 所有票据数字已被划掉,因此我们显示获胜信息图片

图 3.6 – 所有票据数字已被划掉,因此我们显示获胜信息

到目前为止,前端所做的只是像好孩子一样听从和服从。然而,他们现在已经足够成熟,可以被人听到。从 JavaScript 开始,我们将通过发出请求或发送信息与 Django 进行通信,后端将以两种方式做出回应:要么以 JSON(如我们在本节中学到的)形式,要么以渲染的 HTML 形式。

后端渲染 HTML

在本章中,我们将迈出 HTML over WebSockets 原理的第一步。后端将负责渲染 HTML,将责任从 JavaScript 中移除,简化其任务。另一方面,我们将避免需要集成 React、Vue 或 Angular 等框架以及 HTTP 客户端的 API。

目标将是使用公制系统为成年人构建一个体质指数BMI)计算器。所有计算和 HTML 创建都将由 Django 任务处理。

示例中的所有代码都可以在github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-3/Rendering%20HTML找到。

我们将要求输入身高(厘米)和体重(公斤)。获取它的公式是体重(公斤)/(身高(米))2。

在 Python 中,它将被翻译为以下内容:weight / (height ** 2)

其结果将指示状态:

![表 1.1 – BMI 状态图片

表 1.1 – BMI 状态

例如,如果我体重 78 公斤,身高 180 厘米(或 1.8 米),计算结果将是 78 / (1.8 ** 2),结果是24。我会处于正常状态,仅差一点就到超重 – 我想这是生活对我放弃每天巧克力的警告:

  1. 我首先在 app/simple_app/consumers.py 中添加了一个名为 BMIConsumer 的消费者:

    from django.template.loader import render_to_string
    class BMIConsumer(JsonWebsocketConsumer):
    
        def connect(self):
            self.accept()
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            pass
    
        def receive_json(self, data):
            """Event when data is received"""
            height = data['height'] / 100
            weight = data['weight']
            bmi = round(weight / (height ** 2), 1)
            self.send_json(
                content={
                        "action": "BMI result",
                        "html": render_to_string(
                            "components/_bmi_result.html",
                            {"height": height, "weight": 
                                weight, "bmi": bmi}
                        )
                }
            )
    

第一次,我们将从客户端接收信息。客户端将提供未来表单的体重和身高值,而我们则返回准备显示的 HTML。

让我们解释一下前面代码片段中发生的事情:

  • 我们导入 Django 函数以渲染 HTML:from django.template.loader import render_to_string

  • 所有事情都发生在 receive_json 函数中。通过 data['height']data['weight'],我们收集了我们将从 JavaScript 发送的两条数据。

  • 使用 round(weight / (height ** 2), 1) 计算指数。

  • 我们向客户端发送一个包含两个字段的 JSON:"action",我们简单地通知客户端要采取什么操作,以及 "html",其中包含从 render_to_string 生成的 HTML。

  1. 编辑 hello_world/asgi.py 并添加指向 BMIConsumer/ws/bmi/ 路径。

    # hello_world/asgi.py
    Import
    from django.core.asgi import get_asgi_application
    from channels.auth import AuthMiddlewareStack
    from channels.routing import ProtocolTypeRouter, URLRouter
    from django.urls import re_path
    from app.simple_app.consumers import EchoConsumer, BingoConsumer, BMIConsumer # Update
    
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hello_world.settings')
    
    application = ProtocolTypeRouter({
        # Django's ASGI application to handle traditional 
        HTTP requests
        "http": get_asgi_application(),
        # WebSocket handler
        "websocket": AuthMiddlewareStack(
            URLRouter([
                re_path(r"^ws/echo/$", EchoConsumer.
                    as_asgi()),
                re_path(r"^ws/bingo/$", BingoConsumer.
                    as_asgi()),
                re_path(r"^ws/bmi/$", BMIConsumer.
                    as_asgi()), # New line
            ])
        ),
    })
    
  2. 我们在 app/simple_app/templates/bmi.html 中创建一个新的 HTML 文件,它将包含表单和将发送信息的 JavaScript:

    {# app/simple_app/templates/bmi.html #}
    <! doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-
                scalable=no, initial-scale=1.0, maximum-
                  scale=1.0, minimum-scale=1.0">
        <title>BMI Calculator</title>.
    </head>
    <body>
        <h1>BMI Calculator</h1>
        <label for="height"> Height (cm):
            <input type="text" name="height" id="height">
        </label>
        <label for="weight"> Weight (kg)
            <input type="text" name="weight" id="weight">
        </label>
        <input type="button" id="calculate" value=
            "Calculate">
        <div id="result"></div>
    
        <script>
            // Connect to WebSockets server 
            (BingoConsumer)
            const myWebSocket = new WebSocket("ws://{{ 
                request.get_host }}/ws/bmi/");
    
            // Event when a new message is received by 
            WebSockets
            myWebSocket.addEventListener("message", 
               (event) => {
                const myData = JSON.parse(event.data);
                switch (myData.action) {
                    case "BMI result":
                        document.getElementById("result"). 
                            innerHTML = myData.html;
                        break;
                }
            });
    
            document.querySelector('#calculate'). 
                addEventListener('click', () => {
                const height = parseFloat(document.
                    querySelector('#height'). value);
                const weight = parseFloat(document.
                    querySelector('#weight'). value);
                myWebSocket.send(JSON.stringify({
                    height: height,
                    weight: weight
                }));
            });
    
        </script>
    </body>
    </html>
    

机制很简单。当 "action""BMI results" 时,它将在适当的位置注入 HTML。

  1. 我们需要一个视图来显示我们创建的模板。我们在 app/simple_app/views.py 中添加了指向模板的 bmi 函数:

    from django.shortcuts import render
    
    def index(request):
        return render(request, 'index.html', {})
    
    def bingo(request):
        return render(request, 'bingo.html', {})
    
    def bmi(request): # New function
        return render(request, 'bmi. html', {})
    
  2. hello_world/urls.py 中,我们包含了 /bmi/ 路径:

    from django.contrib import admin
    from django.urls import path
    from app.simple_app import views
    
    urlpatterns = [
        path('', views.index, name='index'),
        path('bingo/', views.bingo, name='bingo'),
        path('bmi/', views.bmi, name='bmi'), # New line
        path('admin/', admin.site.urls),
    ]
    

现在,当我们输入 http://hello.localhost/bmi/ 时,我们将看到带有表单的网站:

图 3.7 – 表单已显示,准备使用

图 3.7 – 表单已显示,准备使用

  1. 我们只需要我们将用于显示内容的 HTML 组件。我们在 app/simple_app/templates/components/_bmi_result.html 中创建了一个包含以下内容的文档:

    <p><strong> Weight</strong> {{ weight }} Kg</p>
    <p><strong>Height</strong> {{ height }} m</p>
    <p><p><strong>BMI</strong> {{ bmi }}< /p>
    {% if bmi < 18.5 %}
    <p>Underweight</p>
    {% elif bmi < 25 %}
    <p>Normal</p>
    {% elif bmi < 30 %}
    <p>Overweight</p>
    {% else %}
    <p>Obese</p>
    {% endif %}
    

一切准备就绪,你现在可以计算你的体质指数。警告!我只负责错误;对于任何其他问题,你应该咨询营养师。

图 3.8 – 当表单填写完毕并点击“计算”时,组件的 HTML 显示

图 3.8 – 当表单填写完毕并点击“计算”时,组件的 HTML 显示

摘要

我们已经掌握了使用 WebSocket 协议在前后端之间创建双向通信通道的技能。我们可以发送纯文本、JSON 或 HTML——完全异步,无需等待。我们甚至知道如何让后端处理渲染 HTML 片段,这样访客就不会注意到延迟。

尽管我们已经学到了很多东西,但我们仍然有一些问题,比如后端只能向单个客户端发送信息,而不能向组发送。此外,我们仍然不知道如何与数据库交互、创建会话或识别用户。没有所有这些元素,我们将无法制作一个促进两个访客之间通信或操作数据库的应用程序。我们需要更深入地学习。

在下一章中,我们将介绍数据库模型,并创建一个全新的浏览-读取-编辑-添加-删除BREAD)功能,用于一个全新的项目。

BREAD 是 CRUD 的一种进化

当你想创建一个完整的数据处理系统(创建-读取-更新-删除)时,CRUD是众所周知的。它传统上用于接口、API、数据库和 Web 应用中,但它没有考虑分页、搜索或排序等操作。BREAD 作为一种扩展而生,旨在强调信息必须是可导航的,浏览:浏览-读取-编辑-添加-删除。

参考:guvena.wordpress.com/2017/11/12/bread-is-the-new-crud/.

第四章:与数据库一起工作

本章的目的是不教你如何使用 Django 与数据库交互或创建迁移——我假设你已经具备这些基本技能。本章将展示如何在现实场景中与 Channels 实例反复交互的模型进行工作。

除非应用程序仅由外部 API 供电,否则在任何现代 Web 开发中,拥有数据库是一个基本要求。需求范围可以从简单地以有序方式存储纯文本,到认证系统,再到管理用户之间复杂连接结构。换句话说,如果你想构建一个实用的项目,你必须连接到数据库。

幸运的是,Django 与最流行的关系型数据库兼容:PostgreSQL、MariaDB、MySQL、Oracle 和 SQLite。而且如果还不够,我们还可以通过社区创建的扩展连接到其他可能性:CockroachDBFirebirdGoogle Cloud SpannerMicrosoft SQL Server

我们将专注于创建一个实时应用程序来管理数据库。我们将学习如何执行BREAD的最小功能:浏览-读取-编辑-添加-删除,包括简单的分页。还有什么比创建一个社交网络更好的例子吗?信息应该尽可能少地延迟保存、排序和显示给所有用户。如果响应非常慢,那么我们就未能提供一个实时系统,而是一个无聊的电子邮件系统。

为了教学目的,我们将创建一个无政府主义社交网络。任何访客,无需预先注册,都将能够操纵任何用户的数据。如果你觉得不安全,可以创建一个免责声明页面,呼吁人性,并建议不要更改他人的内容或等待下一章,我们将加入一个完整的注册和身份验证系统。

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

  • 将信息插入数据库

  • 渲染数据库信息

  • 使用分页器限制查询

  • 从数据库中删除行

  • 更新数据库中的行

此外,我们将加入一些 CSS 代码以增强视觉效果,并将所有逻辑移至后端,只留下客户端管理事件的责任。

技术要求

我们将基于前几章积累的所有知识。如果你想从一个模板开始,可以使用以下结构,我将在未来的项目中使用:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/initial-template

在这里,你将找到一个样本项目,该项目已经准备好包含我们在前几章中提到的不同点:

  • 与 Docker 集成

  • 与通道一起工作的最小结构

  • 使用 PostgreSQL 连接到数据库

  • 一个 HTML 主页

  • 一个连接到 Channels 的最小 JavaScript 文件

对于这个项目,我创建了一个模板的分支并做了一些小的修改。您可以使用这两个模板中的任何一个,尽管我推荐使用分支以获得简单的美学。您可以从这里下载它:github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/social-network_step_1。我已经将项目名称改为 social_network,应用改为 website。我还将消费者重命名为 SocialNetworkConsumer

最后,数据库或模型中已添加了一个名为 Message 的模式,位于 app/website/models.py,我们将使用它来管理用户创建的所有消息:

from django.db import models
class Message(models.Model):
    author = models.CharField(max_length=100)
    text = models.TextField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)
    class Meta:
        db_table = "messages"
        verbose_name_plural = "Messages"
    def __str__(self):
        return self.text[:10] + "..."

包含的字段是最少的:author 用于存储作者姓名,text 用于消息文本,created_at 用于记录创建日期,以便稍后排序。

要设置项目,您必须使用 Docker:

docker-compose up

打开您喜欢的浏览器,输入 http://social-network.localhost。您应该找到以下结果。

![图 4.1 – 在域名 http://social-network.localhost 上显示静态 HTML 页面图 4.1 – 在域名 http://social-network.localhost 上显示静态 HTML 页面

图 4.1 – 在域名 http://social-network.localhost 上显示静态 HTML 页面

从视觉上看,它过于...简约。但它包含了所有与工作相关的基本元素。

接下来,我们将逐步构建应用程序,涉及编辑 Message 表或之后查询整个流程。

将信息插入数据库

现在我们已经准备好了一个几乎空的项目,但配置了 Channels,渲染了一个简单的静态 HTML。第一步是在数据库中 INSERT 或保存新信息。为此,我们需要最少的 HTML。我们将包括一个包含两个字段的表单:namemessage。我们还将留出一个空间来显示我们列出的未来消息。

app/website/templates/index.html 中创建一个 HTML 文件,内容如下。

首先,我们将引入一个 CSS 文件和一个 JavaScript 文件。目前,我们将文件包含在标题中:

{% load static %}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no,
              initial-scale=1.0, maximum-scale=1.0, 
                  minimum-scale=1.0">
    <title>Social Network</title>
    <link rel="stylesheet" href="{% static 'css/main.css' %}">
    <script defer src="img/index.js' %}">
    </script>
</head>

接下来,为了拥有主机和方案(HTTP 或 HTTPS),我们必须将其作为一个数据集包含在内,我们将在 JavaScript 中稍后收集它。我们已添加一个表单框和另一个用于列出消息的表单,目前我们不会使用它:

<body
        data-host="{{ request.get_host }}"
        data-scheme="{{ request.scheme }}"
>

以下是我们将使用的 HTML 表单来捕获和保存新消息。在未来,JavaScript 将负责检索所有信息:

    <div class="container">
        <header>
            <h1>Social Network</h1>
        </header>
        <main id="main">
            <section class="message-form">
                <form>
                    <input
                            type="text"
                            placeholder="Name"
                            id="message-form__author"
                            class="message-form__author" 
                               class="message-form__author"
                            name="author"
                            required
                    >
                    <textarea
                            name="message"
                            placeholder="Write your message 
                               here..."
                            id="message-form__text"
                            class="message-form__text" 
                                class="message-form__text"
                            required
                    ></textarea>
                    <input
type="submit"
class="message-form__submit"id="message-form__submit"
value="Send"
                    >
                </form>
            </section>
            <!-- End Form for adding new messages -->

然后,我们将定义一个地方来列出从数据库接收到的所有消息。我们还将包括按钮,以五元素为一组分页显示结果:

            <section id="messages">
                <div id="messages__list"></div>
                <button class="button" 
                    id="messages__previous-page" disabled> 
                        Previous</button>
                <button class="button" id="messages__next-
                    page">Next</button>
            </section>
            <!-- End Messages -->
        </main>
    </div>
</body>
</html>

/static/css/main.css 文件中,我们将添加一些最小化样式,以便感觉我们身处 21 世纪:

:root {
    --color__background: #f6f4f3;
    --color__gray: #ccc;
}

我们添加了一些字体,使视觉效果更加明亮。Helvetica 与所有东西都很搭配,但您可以使用您喜欢的任何字体。您不会伤害 Django 的感情:

* {
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    box-sizing: border-box;
}

我们添加的一些改进包括纠正正文边距和将内容居中于container

body {
    margin: 0;
    background-color: var(--color__background);
}
.container {
    margin: 0 auto;
    padding: 1rem;
    max-width: 40rem;
}

我们还将对按钮进行样式设计,使其看起来不像 90 年代的产品:

. button {
    display: inline-block;
    padding: 0.5rem 1rem;
    background-color: var(--color__gray);
    border: 0;
    cursor: pointer;
    text-decoration: none;
}
. button:hover {
    filter: brightness(90%);
}

我们还使用了一种不那么复古的外观来设计表单或元素。设计不应与后端工作冲突:

.message-form__author, .message-form__text {
    display: block;
    width: 100%;
    outline: none;
    padding: .5rem;
    resize: none;
    border: 1px solid var(--color__gray);
    box-sizing: border-box;
}
.message-form__submit {
    display: block;
    width: 100%;
    outline: none;
    padding: .5rem;
    background-color: var(--color__gray);
    border: none;
    cursor: pointer;
    font-weight: bold;
}
.message-form__submit:hover {
    filter: brightness(90%);
}
.message {
    border: 1px solid var(--color__gray);
    border-top: 0;
    padding: .5rem;
    border-radius: .5rem;
}
.message__author {
    font-size: 1rem;
}
.message__created_at {
    colour: var(--color__gray);
}
.message__footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

如果您启动 Docker,并输入http://social-network.localhost,您将找到以下页面:

![Figure 4.2 – 带有 CSS 样式的输入新消息的表单Figure 4.02_B18321.jpg

Figure 4.2 – 带有 CSS 样式的输入新消息的表单

随意添加您需要的任何内容,甚至是一个 CSS 框架。

现在我们将包括 JavaScript 事件,将表单数据发送到消费者。我们将在/static/js/index.js中创建一个新文件,内容如下:

/*
    VARIABLES
*/
// Connect to WebSockets server (SocialNetworkConsumer)
const myWebSocket = new WebSocket(`${document.body.
    dataset.scheme === 'http' ? 'ws' : 'wss'}://${ 
    document.body.dataset.host }/ws/social-network/`);
const inputAuthor = document.querySelector("#message-
    form__author");
const inputText = document.querySelector("#message-
    form__text");
const inputSubmit = document.querySelector("#message-
    form__submit");
/*
    FUNCTIONS
*/
/**
* Send data to WebSockets server
* @param {string} message
* @param {WebSocket} webSocket
* @return {void}
*/
function sendData(message, webSocket) {
    webSocket.send(JSON.stringify(message));
}
/**
* Send new message
* @param {Event} event
* @return {void}
*/
function sendNewMessage(event) {
    event.preventDefault();
    // Prepare the information we will send
    const newData = {
        "action": "add message",
        "data": {
            "author": inputAuthor.value,
            "text": inputText.value
        }
    };
    // Send the data to the server
    sendData(newData, myWebSocket);
    // Clear message form
    inputText.value = "";
}
/*
    EVENTS
*/
// Event when a new message is received by WebSockets
myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    document.querySelector(data.selector). innerHTML = 
        data.html;
});
// Sends new message when you click on Submit
inputSubmit.addEventListener("click", sendNewMessage);

在变量部分,我们捕获所有需要捕获所有事件的 HTML 元素,并创建一个WebSockets连接。sendData函数用于向后端发送消息,而sendNewMessage在点击提交按钮时执行。JSON 将始终以以下结构发送:

{
        "action": "text",
        "data": {}
}

我们修改消费者以接收信息并保存它。使用以下内容编辑app/website/consumers.py

from channels.generic.websocket import JsonWebsocketConsumer
from django.template.loader import render_to_string
from . models import Message
from asgiref.sync import async_to_sync
class SocialNetworkConsumer(JsonWebsocketConsumer):
    room_name = 'broadcast
    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept()
        # Assign the Broadcast group
        async_to_sync(self.channel_layer.group_add)
            (self.room_name, self.channel_name)
        # Send you all the messages stored in the database.
    def disconnect(self, close_code):
        """Event when client disconnects"""
        # Remove from the Broadcast group
        async_to_sync(self.channel_layer.group_discard)
            (self.room_name, self.channel_name)
    def receive_json(self, data_received):
        """
            Event when data is received
            All information will arrive in 2 variables:
            'action', with the action to be taken
            'data' with the information
        """
        # Get the data
        data = data_received['data']
        # Depending on the action we will do one task or 
        another.
        match data_received['action']:
            case 'add message':
                # Add message to database
                Message.objects.create(
                    author=data['author'],
                    text=data['text'],
                )

您可以看到,当连接时,客户端被添加到房间中,当断开连接时被移除。在第五章 分离房间中的通信中,我们将深入讨论房间的可能性,但现在,我们将所有用户分组到一个名为broadcast的单个房间中。

当接收到文本为'add message'的操作时,我们直接使用从前端发送的信息创建一个新的消息。

我们已经存储了信息!尽管我们目前无法看到或排序它。

到目前为止的所有代码都可以在以下存储库中找到,这是活动的第一部分:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/social-network_step_2

在下一节中,我们将通过 WebSockets 直接以 HTML 格式打印我们保存的所有消息。

渲染数据库信息

我们已经构建了一个表单,通过 WebSockets 连接将新消息发送到后端,我们在消费者中捕获它,然后将其存储在数据库中。

现在我们将获取数据库中的所有消息,使用 Django 的模板引擎渲染它们,通过我们保持的连接将 HTML 发送到客户端,并将内容注入到适当的前端元素中。

/app/website/templates/components/_list-messages.html 路径下创建一个 HTML 模板,该模板将生成所有消息块,其内容如下:

{% for message in messages %}
    <article class="message" id="message--{{ message.id
        }}">
        <h2 class="message__author">{{ message.author }}
        </h2>
        <p class="message__text">{{ message.text }}</p>
        <footer class="message__footer">
            <time class="message__created_at">{{ 
                message.created_at }}</time>
            <div class="message__controls">
                <button class="button messages__update" 
                  data-id="{{ message.id }}"> Edit</button>
                <button class="button messages__delete" 
                    data-id="{{ message.id }}"> Delete
                        </button>
            </div>
        </footer>
    </article>
{% endfor %}

目前,编辑删除按钮仅用于装饰。稍后,我们将赋予它们功能。

编辑 app/website/consumers.py 中的消费者,以包括一个返回消息的操作:

from channels.generic.websocket import JsonWebsocketConsumer
from django.template.loader import render_to_string
from . models import Message
from asgiref.sync import async_to_sync
class SocialNetworkConsumer(JsonWebsocketConsumer):
    room_name = 'broadcast'
    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept()
        # Assign the Broadcast group
        async_to_sync(self.channel_layer.group_add)
            (self.room_name, self.channel_name)
        # Send you all the messages stored in the database.
        self.send_list_messages()
    def disconnect(self, close_code):
        """Event when client disconnects"""
        # Remove from the Broadcast group
        async_to_sync(self.channel_layer.group_discard)
            (self.room_name, self.channel_name)
    def receive_json(self, data_received):
        """
            Event when data is received
            All information will arrive in 2 variables:
            'action', with the action to be taken
            'data' with the information
        """
        # Get the data
        data = data_received['data']
        # Depending on the action we will do one task or 
          another.
        match data_received['action']:
            case 'add message':
                # Add message to database
                Message.objects.create(
                    author=data['author'],
                    text=data['text'],
                )
                # Send messages to all clients
                self.send_list_messages()
            case 'list messages':
                # Send messages to all clients
                self.send_list_messages()
    def send_html(self, event):
        """Event: Send html to client"""
        data = {
            'selector': event['selector'],
            'html': event['html'],
        }
        self.send_json(data)
    def send_list_messages(self):
        """ Send list of messages to client"""
        # Filter messages to the current page
        messages = Message.objects.order_by('-created_at')
        # Render HTML and send to client
        async_to_sync(self.channel_layer.group_send)(
            self.room_name, {
                type': 'send.html', # Run 'send_html()' 
                    method
                'selector': '#messages__list',
                'html': render_to_string
                ('components/_list-messages.html', { 
                    'messages': messages})
            }
        )

当前端发送 'list messages' 动作或创建 WebSockets 连接时,我们将执行 send_list_messages() 函数。后端将获取所有消息,渲染 HTML,并将消息发送到前端。在函数内部,我们执行一个查询以按降序获取所有消息,如果你之前已经与模型工作过,这是一个基本操作。重要的是下一部分的内容:

async_to_sync(self.channel_layer.group_send)(
            self.room_name, {
                type': 'send.html', # Run 'send_html()' 
                    method
                'selector': '#messages__list',
                'html': render_to_string('components/_list-
                   messages.html', { 'messages': messages})
            }
        )

当向一个组发送信息时,它将始终异步执行,以避免阻塞主线程,但与数据库通信时必须同步。这两种类型的逻辑如何共存?通过将 self.channel_layer.group_send() 转换为同步函数,这得益于 async_to_sync()

self.channel_layer.group_send() 是一个不寻常的函数。它的第一个参数必须是您想要发送信息到的房间名称,在我们的例子中将是 self.room_name,它在消费者开始时声明。第二个参数必须有一个字典,其中 type 是要执行函数的名称(如果有 _,则必须替换为点),其余的键是我们想要传递给函数的信息。在 send_html 函数内部,我们使用 event[] 捕获先前信息。最后,我们以与 第三章 中相同的方式,即通过 send_json(),将数据发送到客户端,该章节是 将 WebSockets 添加到 Django

当我们想要注入 HTML 时,我们将发送 html 键和渲染的 HTML,以及 selector 来告诉 JavaScript 将其注入的位置。后端将决定每个元素应该放在哪里。

当在浏览器中查看时,我们将找到我们添加的消息,如图 图 4.3 所示:

![Figure 4.3 – 我们在数据库中保存的所有消息都显示出来]

Figure 4.03_B18321.jpg

Figure 4.3 – 我们在数据库中保存的所有消息都显示出来

到目前为止的所有代码都可以在以下链接中找到:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/social-network_step_3

如果我们为每个用户显示数百或数千条消息会发生什么?性能和内存后果将是灾难性的。我们必须通过限制可以显示的消息数量来避免烟花。因此,我们将将其限制为每页五条消息,并添加一些按钮来在各个切片之间导航。让我们在下一节中看看这个。

使用分页器限制查询

我们可以从数据库中添加和列出消息。但我们必须限制用户可以看到的信息量。一个好的做法是提供一个分页器,以便用户可以浏览所有数据。

按照以下步骤添加简单的分页器:

  1. 修改模板 /app/website/templates/components/_list-messages.html,添加一个简单的分页器,分为两个按钮(前进和后退):

    {% for message in messages %}
        <article class="message" id="message--{{ 
            message.id }}">
            <h2 class="message__author">{{ message.author 
            }}</h2>
            <p class="message__text">{{ message.text }}
            </p>
            <footer class="message__footer">
                <time class="message__created_at">{{ 
                    message.created_at }}</time>
                <div class="message__controls">
                    <button class="button 
                        messages__update" data-id="{{ 
                            message.id }}"> Edit</button>
                    <button class="button 
                        messages__delete" data-id="{{ 
                           message.id }}"> Delete</button>
                </div>
            </footer>
        </article>
    {% endfor %}
    {% if total_pages != 0 %}
        <!-- Paginator -->
        <div id="paginator" data-page="{{ page }}">
            {# The back button on the first page is not 
            displayed #}
            {% if page ! = 1 %}
            <button class="button" id="messages__previous-
                page"> Previous</button>
            {% endif %}
    
            {# The forward button on the last page is not 
            displayed #}
            {% if page ! = total_pages %}
            <button class="button" id="messages__next-
                page">Next</button>
            {% endif %}
        </div>
        <!-- End Paginator -->
    {% endif %}
    
    • 使用 data-page="{{ page }}",我们向 JavaScript 提供了一个计数器,表示我们所在的页面。我们将使用这个数据来创建一个新事件,该事件将触发一个动作,指示我们是否想要转到下一页或返回。

    • 条件 {% if page ! = 1 %} 用于避免在第一页时显示后退按钮。

    • 条件 {% if page ! = total_pages %} 忽略了在最后一页时渲染前进按钮。

  2. 我们向消费者(/app/website/consumers.py)添加了 send_list_messages() 的切片系统:

    from channels.generic.websocket import JsonWebsocketConsumer
    from django.template.loader import render_to_string
    from . models import Message
    from asgiref.sync import async_to_sync
    import math
    
    class SocialNetworkConsumer(JsonWebsocketConsumer):
    
        room_name = 'broadcast
        max_messages_per_page = 5 # New line
    
        def connect(self):
            """Event when client connects"""
            # Accept the connection
            self.accept()
            # Assign the Broadcast group
            async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name)
            # Send you all the messages stored in the database.
            self.send_list_messages()
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            # Remove from the Broadcast group
            async_to_sync(self.channel_layer.
                group_discard)(self.room_name, 
                    self.channel_name)
    
        def receive_json(self, data_received):
            """
                Event when data is received
                All information will arrive in 2 
                variables:
                'action', with the action to be taken
                'data' with the information
            """
    
            # Get the data
            data = data_received['data']
            # Depending on the action we will do one task
             or another.
            match data_received['action']:
                case 'add message':
                    # Add message to database
                    Message.objects.create(
                        author=data['author'],
                        text=data['text'],
                    )
                    # Send messages to all clients
                    self.send_list_messages()
                case 'list messages':
                    # Send messages to all clients
                    self.send_list_messages(data['page'])
                    # Update line
    
        def send_html(self, event):
            """Event: Send html to client"""
            data = {
                'selector': event['selector'],
                'html': event['html'],
            }
            self.send_json(data)
    
        def send_list_messages(self, page=1): 
        # Update line
            "Send list of messages to client"""""
            # Filter messages to the current page
            start_pager = self.max_messages_per_page * 
                (page - 1) # New line
            end_pager = start_pager + 
                self.max_messages_per_page # New line
            messages = Message.objects.order_by('-
                created_at')
            messages_page = messages[start_pager:
                end_pager] # New line
            # Render HTML and send to client
            total_pages = math.ceil(messages.count() / 
                self.max_messages_per_page) # New line    
            async_to_sync(self.channel_layer.group_send)(
                self.room_name, {
                    'type': 'send.html', # Run 
                        'send_html()' method
                    'selector': '#messages__list',
                    'html': render_to_string
                      ('components/_list-messages.html', {
                        'messages': messages_page, 
                        # Update line
                        'page': page, # New line
                        total_pages': total_pages, 
                        # New line
                    })
                }
            )
    
    • 变量 max_messages_per_page = 5 已被添加,用于表示每页的项目数量。

    • 'list messages' 动作现在收集并将要显示的页面传递给 send_list_messages 函数。

    • 我们已更新 send_list_messages。我们允许你指定要显示的页面,我们计算查询切片,并将带有切片、pagetotal_pages 变量的 messages 传递给 render_to_string,这样我们就可以知道我们是否在最后一页。

  3. /static/js/index.js 中,添加两个 JavaScript 函数(goToNextPagegoToPreviousPage),它们将负责翻页。实际上,它们只是使用带有请求列表消息的操作,但在另一个切片中:

    /*
        VARIABLES
    */
    // Connect to WebSockets server (SocialNetworkConsumer)
    const myWebSocket = new WebSocket
    (`${document.body.dataset.scheme === 'http' ? 'ws' : 
    'wss'}://${ document.body.dataset.host }/ws/social-
        network/`);
    const inputAuthor = document.querySelector("#message-
        form__author");
    const inputText = document.querySelector("#message-
        form__text");
    const inputSubmit = document.querySelector("#message-
        form__submit");
    
    /*
        FUNCTIONS
    */
    
    /**
    * Send data to WebSockets server
    * @param {string} message
    * @param {WebSocket} webSocket
    * @return {void}
    */
    function sendData(message, webSocket) {
        webSocket.send(JSON.stringify(message));
    }
    
    /**
    * Send new message
    * @param {Event} event
    * @return {void}
    */
    function sendNewMessage(event) {
        event.preventDefault();
        // Prepare the information we will send
        const newData = {
            "action": "add message",
            "data": {
                "author": inputAuthor.value,
                "text": inputText.value
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
        // Clear message form
        inputText.value = "";
    }
    
    /**
    * Get current page stored in #paginator as dataset
    * @returns {number}
    */
    function getCurrentPage() {
        return parseInt(document.
            querySelector("#paginator"). dataset.page);
    }
    
    /**
    * Switch to the next page
    * @param {Event} event
    * @return {void}
    */
    function goToNextPage(event) {
        // Prepare the information we will send
        const newData = {
            "action": "list messages",
            "data": {
                "page": getCurrentPage() + 1,
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
    }
    
    /**
    * Switch to the previous page
    * @param {Event} event
    * @return {void}
    */
    function goToPreviousPage(event) {
        // Prepare the information we will send
        const newData = {
            "action": "list messages",
            "data": {
                "page": getCurrentPage() - 1,
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
    }
    
    /*
        EVENTS
    */
    
    // Event when a new message is received by WebSockets
    myWebSocket.addEventListener("message", (event) => {
        // Parse the data received
        const data = JSON.parse(event.data);
        // Renders the HTML received from the Consumer
        document.querySelector(data.selector). innerHTML = 
            data.html;
        /* Reassigns the events of the newly rendered HTML */
        // Pagination
        document.querySelector("#messages__next-page")?. 
            addEventListener("click", goToNextPage);
        document.querySelector("#messages__previous-
            page")?. addEventListener("click", 
                goToPreviousPage);
    });
    
    // Sends new message when you click on Submit
    inputSubmit.addEventListener("click", sendNewMessage);
    

那么我们在哪里添加按钮的监听器以执行函数?在 WebSockets"message" 事件中。为什么?每次消息部分更新时,所有的 HTML 都会被删除并重新创建,从后端接收的内容。事件会随着每次更新而消失。我们必须重新分配它们。在重新绘制消息后,我们将分配监听器:

document.querySelector("#messages__next-page")?. 
    addEventListener("click", goToNextPage); 
document.querySelector("#messages__previous-page")?. 
    addEventListener("click", goToPreviousPage);

尝试创建尽可能多的消息(至少六条)以检查分页器是否正常工作。

图 4.4 – 我们显示消息的第一页

图 4.4 – 我们显示消息的第一页

如果我们通过点击下一页按钮翻页,将渲染下一块消息。

图 4.5 – 我们显示消息的最后一页

图 4.5 – 我们显示消息的最后一页

到目前为止的所有代码都可以在以下链接中找到:

Building SPAs with Django and HTML Over-the-Wire 社交网络步骤 4

下一个目标是使用 WebSockets 删除消息,这是一个带有删除具有具体消息 id 的消息的指令的动作——我们将像狙击手一样精确。

从数据库中删除行

在前面的章节中,我们成功构建了一个系统,可以添加新消息、列出它们并进行分页。但到目前为止,我们无法删除任何内容。

项目的结构使得实现起来非常快:

  1. /app/website/templates/components/_list-messages.html 中,我们检查是否为每个按钮添加了一个带有 id 的数据集。我们在列出消息时执行此任务;我们必须知道我们将使用的 id 的来源,以便知道要删除哪个消息:

    <button class="button messages__delete" data-id="{{ 
        message.id }}"> Delete</button>
    
  2. /static/js/index.js 文件中,添加 deleteMessage 函数。我们将使用 ID 捕获数据集,并通过动作 "delete message" 发送到消费者。此外,我们将在分页器的监听器之后添加每个监听器。让我们不要忘记这个位置的原因:每次更改或注入 HTML 的新后端消息后,所有事件都必须重新分配:

    /*
        VARIABLES
    */
    // Connect to WebSockets server (SocialNetworkConsumer)
    const myWebSocket = new WebSocket
        (`${document.body.dataset.scheme === 'http' ? 'ws' 
        : 'wss'}://${ document.body.dataset.host 
        }/ws/social-network/`);
    const inputAuthor = document.querySelector("#message-
        form__author");
    const inputText = document.querySelector("#message-
        form__text");
    const inputSubmit = document.querySelector("#message-
        form__submit");
    
    /*
        FUNCTIONS
    */
    
    /**
    * Send data to WebSockets server
    * @param {string} message
    * @param {WebSocket} webSocket
    * @return {void}
    */
    function sendData(message, webSocket) {
        webSocket.send(JSON.stringify(message));
    }
    
    /**
    * Delete message
    * @param {Event} event
    * @return {void}
    */
    function deleteMessage(event) {
        const message = {
            "action": "delete message",
            "data": {
                "id": event.target.dataset.id
            }
        };
        sendData(message, myWebSocket);
    }
    
    /**
    * Send new message
    * @param {Event} event
    * @return {void}
    */
    function sendNewMessage(event) {
        event.preventDefault();
        // Prepare the information we will send
        const newData = {
            "action": "add message",
            "data": {
                "author": inputAuthor.value,
                "text": inputText.value
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
        // Clear message form
        inputText.value = "";
    }
    
    /**
    * Get current page stored in #paginator as dataset
    * @returns {number}
    */
    function getCurrentPage() {
        return parseInt(document.querySelector("#paginator"). dataset.page);
    }
    
    /**
    * Switch to the next page
    * @param {Event} event
    * @return {void}
    */
    function goToNextPage(event) {
        // Prepare the information we will send
        const newData = {
            "action": "list messages",
            "data": {
                "page": getCurrentPage() + 1,
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
    }
    
    /**
    * Switch to the previous page
    * @param {Event} event
    * @return {void}
    */
    function goToPreviousPage(event) {
        // Prepare the information we will send
        const newData = {
            "action": "list messages",
            "data": {
                "page": getCurrentPage() - 1,
            }
        };
        // Send the data to the server
        sendData(newData, myWebSocket);
    }
    
    /*
        EVENTS
    */
    
    // Event when a new message is received by WebSockets
    myWebSocket.addEventListener("message", (event) => {
        // Parse the data received
        const data = JSON.parse(event.data);
        // Renders the HTML received from the Consumer
        document.querySelector(data.selector). innerHTML = 
            data.html;
        /* Reassigns the events of the newly rendered HTML */
        // Pagination
        document.querySelector("#messages__next-
          page")?.addEventListener("click", goToNextPage);
        document.querySelector("#messages__previous-
           page")?.addEventListener("click", 
               goToPreviousPage);
        // Add to all delete buttons the event
        document.querySelectorAll
            (".messages__delete").forEach(button => {
            button.addEventListener("click", 
                deleteMessage);
        });
    });
    
    // Sends new message when you click on Submit
    inputSubmit.addEventListener("click", sendNewMessage);
    
  3. 现在,使用动作 'delete message' 编辑 /app/website/consumers.py

    # app/website/consumers.py
    from channels.generic.websocket import JsonWebsocketConsumer
    from django.template.loader import render_to_string
    from .models import Message
    from asgiref.sync import async_to_sync
    import math
    
    class SocialNetworkConsumer(JsonWebsocketConsumer):
    
        room_name = 'broadcast'
        max_messages_per_page = 5
    
        def connect(self):
            """Event when client connects"""
            # Accept the connection
            self.accept()
            # Assign the Broadcast group
            async_to_sync(self.channel_layer.group_add)
                (self.room_name, self.channel_name)
            # Send you all the messages stored in the 
            database.
            self.send_list_messages()
    
        def disconnect(self, close_code):
            """Event when client disconnects"""
            # Remove from the Broadcast group
            async_to_sync(self.channel_layer.group_
               discard)(self.room_name, self.channel_name)
    
        def receive_json(self, data_received):
            """
                Event when data is received
                All information will arrive in 2 variables:
                'action', with the action to be taken
                'data' with the information
            """
    
            # Get the data
            data = data_received['data']
            # Depending on the action we will do one task or another.
            match data_received['action']:
                case 'add message':
                    # Add message to database
                    Message.objects.create(
                        author=data['author'],
                        text=data['text'],
                    )
                    # Send messages to all clients
                    self.send_list_messages()
                case 'list messages':
                    # Send messages to all clients
                    self.send_list_messages(data['page'])
                case 'delete message':
                    # Delete message from database
                    Message.objects.get
                       (id=data['id']).delete() # New line
                    # Send messages to all clients
                    self.send_list_messages() # New line
    
        def send_html(self, event):
            """Event: Send html to client"""
            data = {
                'selector': event['selector'],
                'html': event['html'],
            }
            self.send_json(data)
    
        def send_list_messages(self, page=1):
            """Send list of messages to client"""
            # Filter messages to the current page
            start_pager = self.max_messages_per_page * 
               (page - 1)
            end_pager = start_pager + 
                self.max_messages_per_page
            messages = Message.objects.order_by('-
                created_at')
            messages_page = messages
                [start_pager:end_pager]
            # Render HTML and send to client
            total_pages = math.ceil(messages.count() / 
                self.max_messages_per_page)
            async_to_sync(self.channel_layer.group_send)(
                self.room_name, {
                    'type': 'send.html', # Run 
                     'send_html()' method
                    'selector': '#messages__list',
                    'html': render_to_string
                      ('components/_list-messages.html', {
                        'messages': messages_page,
                        'page': page,
                        'total_pages': total_pages,
                    }) 
                }
            )
    

使用 Message.objects.get(id=data['id']).delete(),我们直接从前端发送给我们的 id 删除消息。最后,我们使用 self.send_list_messages() 更新所有客户端的消息列表。

到目前为止的所有代码都可以在以下链接中找到:

Building SPAs with Django and HTML Over-the-Wire 社交网络步骤 5

我们已经完成了创建数据库中删除行的功能。现在,我们可以选择性地删除消息。在练习的下一部分,我们将通过添加修改现有消息的能力来完成构建社交网络。有了这个新功能,我们将拥有所有 BREAD 的字母。

更新数据库中的行

在这个练习的最后部分,我们将通过添加一个表单来修改信息来完成构建社交网络。

所有完成的代码都可以在以下链接中找到:

Building SPAs with Django and HTML Over-the-Wire 社交网络步骤 6

让我们开始:

  1. /app/website/templates/components/_edit-message.html 中创建一个新的 HTML 组件,内容如下:

    <form class="update-form" data-id="{{ message.id }}">
        <input
                type="text"
                placeholder="Name"
                id="message-form__author--update"
                class="message-form__author" 
                name="author"
                required
                value="{{ message.author }}"
        >
        <textarea
                name="message"
                placeholder="Write your message here..."
                id="message-form__text--update"
                class="message-form__text" 
                required
        >{{ message.text }}</textarea>
        <input
                type="submit"
                class="message-form__submit" class="message-form__submit" 
                id="message-form__submit--update"
                value="Update"
        >
    </form>
    

HTML 组件在创建消息时几乎相同,只是细节上有所不同,我们将要修改的消息的id存储在一个我们托管在<form>标签中的数据集中,使用data-id="{{ message.id }}"进行填充,并填写所有字段。

  1. 在消费者中创建一个请求编辑表单的操作:

    case 'open edit page':
    self.open_edit_page(data['id'])
    

此操作仅渲染并发送前一个组件,以便用户可以编辑以下信息:

def open_edit_page(self, id):
        """Send the form to edit the message"""
        message = Message.objects.get(id=id)
        async_to_sync(self.channel_layer.group_send)(
            self.room_name, {
                'type': 'send.html', # Run 
                   'send_html()' method
                'selector': f'#message--{id}',
                'html': render_to_string
                   ('components/_edit-message.html',
                       {'message': message})
            }
        )
  1. 现在,在消费者中添加操作以从表单收集信息、更新数据库并渲染消息列表:

    case 'update message':
                    # Update message in database
                    Message.objects.filter(id=data['id']). update(
                        author=data['author'],
                        text=data['text'],
                    )
                    # Send messages to all clients
                    self.send_list_messages()
    

整个集成消费者,包括更新操作,将看起来像这样:

from channels.generic.websocket import JsonWebsocketConsumer
from django.template.loader import render_to_string
from .models import Message
from asgiref.sync import async_to_sync
import math

class SocialNetworkConsumer(JsonWebsocketConsumer):

    room_name = 'broadcast'
    max_messages_per_page = 5

    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept()
        # Assign the Broadcast group
        async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name)
        # Send you all the messages stored in the database.
        self.send_list_messages()

    def disconnect(self, close_code):
        """Event when client disconnects"""
        # Remove from the Broadcast group
        async_to_sync(self.channel_layer.
           group_discard)(self.room_name, 
              self.channel_name)

    def receive_json(self, data_received):
        """
            Event when data is received
            All information will arrive in 2 variables:
            'action', with the action to be taken
            'data' with the information
        """

        # Get the data
        data = data_received['data']
        # Depending on the action we will do one task or another.
        match data_received['action']:
            case 'add message':
                # Add message to database
                Message.objects.create(
                    author=data['author'],
                    text=data['text'],
                )
                # Send messages to all clients
                self.send_list_messages()
            case 'list messages':
                # Send messages to all clients
                self.send_list_messages(data['page'])
            case 'delete message':
                # Delete message from database
                Message.objects.get
                   (id=data['id']).delete()
                # Send messages to all clients
                self.send_list_messages()
            case 'open edit page':
                self.open_edit_page(data['id'])
            case 'update message':
                # Update message in database
                Message.objects.filter(id=data['id']). 
                    update(
                    author=data['author'],
                    text=data['text'],
                ) # New block
                # Send messages to all clients
                self.send_list_messages() # New line

    def send_html(self, event):
        """Event: Send html to client"""
        data = {
            'selector': event['selector'],
            'html': event['html'],
        }
        self.send_json(data)

    def send_list_messages(self, page=1):
        """Send list of messages to client"""
        # Filter messages to the current page
        start_pager = self.max_messages_per_page * (page - 1)
        end_pager = start_pager + 
            self.max_messages_per_page
        messages = Message.objects.order_by('-
            created_at')
        messages_page = 
            messages[start_pager:end_pager].
        # Render HTML and send to client
        total_pages = math.ceil(messages.count() / 
            self.max_messages_per_page)
        async_to_sync(self.channel_layer.group_send)(
            self.room_name, {
                'type': 'send.html', # Run 
                    'send_html()' method
                'selector': '#messages__list',
                'html': render_to_string
                  ('components/_list-messages.html', {
                    'messages': messages_page,
                    'page': page,
                    'total_pages': total_pages,
                })
            }
        )

    def open_edit_page(self, id):
        """Send the form to edit the message"""
        message = Message.objects.get(id=id)
        async_to_sync(self.channel_layer.group_send)(
            self.room_name, {
                'type': 'send.html', # Run 
                    'send_html()' method
                'selector': f'#message--{id}',
                'html': render_to_string
                    ('components/_edit-message.html', 
                        {'message': message})
            }
        )
  1. 我们在前端创建了必要的事件,以请求表单、收集信息并发送它。

我们连接到频道并收集用户可以写入新消息的表单字段:

/*
    VARIABLES
*/
// Connect to WebSockets server (SocialNetworkConsumer)
const myWebSocket = new WebSocket(`${document.body.dataset.scheme === 'http' ? 'ws' : 'wss'}://${ document.body.dataset.host }/ws/social-network/`);
const inputAuthor = document.querySelector("#message-
     form__author");
const inputText = document.querySelector("#message-  
    form__text");
const inputSubmit = document.querySelector("#message-
    form__submit");

我们使用最小化和基本的功能,如发送新信息、显示更新表单、发送更新信息、删除特定元素和分页管理:

/*
    FUNCTIONS
*/

/**
* Send data to WebSockets server
* @param {string} message
* @param {WebSocket} webSocket
* @return {void}
*/
function sendData(message, webSocket) {
    webSocket.send(JSON.stringify(message));
}

/**
* Displays the update form
* @param {Event} event
* @return {void}
*/
function displayUpdateForm(event) {
    const message = {
        "action": "open edit page",
        "data": {
            "id": event.target.dataset.id
        }
    };
    sendData(message, myWebSocket);
}

/**
* Update message
* @param {Event} event
* @return {void}
*/
function updateMessage(event) {
    event.preventDefault();
    const message = {
        "action": "update message",
        "data": {
            "id": event.target.dataset.id,
            "author": event.target.querySelector("#message-form__author--update"). value,
            "text": event.target.querySelector("#message-form__text--update"). value
        }
    };
    sendData(message, myWebSocket);
}

/**
* Delete message
* @param {Event} event
* @return {void}
*/
function deleteMessage(event) {
    const message = {
        "action": "delete message",
        "data": {
            "id": event.target.dataset.id
        }
    };
    sendData(message, myWebSocket);
}

/**
* Send new message
* @param {Event} event
* @return {void}
*/
function sendNewMessage(event) {
    event.preventDefault();
    // Prepare the information we will send
    const newData = {
        "action": "add message",
        "data": {
            "author": inputAuthor.value,
            "text": inputText.value
        }
    };
    // Send the data to the server
    sendData(newData, myWebSocket);
    // Clear message form
    inputText.value = "";
}

/**
* Get current page stored in #paginator as dataset
* @returns {number}
*/

function getCurrentPage() {
    return parseInt(document.querySelector("#paginator"). dataset.page);
}

/**
* Switch to the next page
* @param {Event} event
* @return {void}
*/
function goToNextPage(event) {
    // Prepare the information we will send
    const newData = {
        "action": "list messages",
        "data": {
            "page": getCurrentPage() + 1,
        }
    };
    // Send the data to the server
    sendData(newData, myWebSocket);
}

/**
* Switch to the previous page
* @param {Event} event
* @return {void}
*/
function goToPreviousPage(event) {
    // Prepare the information we will send
    const newData = {
        "action": "list messages",
        "data": {
            "page": getCurrentPage() - 1,
        }
    };
    // Send the data to the server
    sendData(newData, myWebSocket);
}

接收来自后端信息的最重要的事件是"message"。每次我们收到新数据时,我们都会打印它并重新捕获所有事件。如果没有这个持续的重新分配,我们将在每次新的渲染或 HTML 重绘中丢失所有事件:

/*
    EVENTS
*/

// Event when a new message is received by WebSockets
myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    document.querySelector(data.selector). innerHTML = 
        data.html;
    /* Reassigns the events of the newly rendered HTML */
    // Pagination
    document.querySelector("#messages__next-page")?. 
        addEventListener("click", goToNextPage);
    document.querySelector("#messages__previous-   
        page")?. addEventListener("click", 
            goToPreviousPage);
    // Add to all delete buttons the event
    document.querySelectorAll(". messages__delete"). forEach(button => {
        button.addEventListener("click", deleteMessage);
    });
    // Add to all update buttons the event
    document.querySelectorAll(". messages__update"). forEach(button => {
        button.addEventListener("click", displayUpdateForm);
    });
    // Add to the update form the event
    document.querySelectorAll(". update-form"). forEach(form => {
        form.addEventListener("submit", updateMessage);
    });
});

// Sends new message when you click on Submit
inputSubmit.addEventListener("click", sendNewMessage);

所有前面的代码都是 JavaScript 的最终版本:

  • 已添加displayUpdateForm函数,用于要求消费者在消息所在的位置绘制编辑表单。

  • 已创建updateMessage函数,用于将新信息发送到消费者以更新消息。

  • 包含了按钮监听器,以便在分页和删除事件后立即更新。

图 4.6 – 点击编辑时显示编辑表单

图 4.06_B18321.jpg

图 4.6 – 点击编辑时显示编辑表单

我们做到了!BREAD 已经完成。我们现在可以把它涂上黄油,让它尽可能多地被顾客消费。

记得用不同的浏览器打开练习,以欣赏同步的魔法。任何用户的任何操作都将被其他人可视化。

摘要

我们已经能够将消费者连接到数据库以管理其信息,并通过注入新的渲染 HTML 结构来回复。逐渐地,一个非常基本的实时社交网络被建立起来,用于插入消息、列出消息、过滤消息、更新消息和删除消息。前端有一个简单的角色——处理事件、发送数据和通过 WebSockets 接收 HTML。

目前,与群体歧视相关的限制有几个。当一个动作被执行时,它会传播到所有用户,意味着所有动作都会同时影响所有访客。基本上,这是好事,我们希望它发生,但并非在所有流程中。当插入新消息时,我想让每个人都更新他们的消息列表吗?当然,是的——当编辑或删除时也是如此。尽管应该避免某些应该私有的动作。目前,如果一个用户更改页面,所有人都会更改页面。这就是我们要深入研究频道房间提供的可能性的原因:一种允许我们向特定的客户端或我们定义的特定组发送数据的机制。使用这种技术,我们可以通过整合私人房间或仅限于某些客户的信息来更进一步。

在下一章中,我们将讨论创建房间和它们最佳管理的不同技术。关于数据库所获得的所有知识将帮助我们创建一个简单的聊天工具,这将允许我们在两个客户之间保持私密对话,限制群组,或向所有连接的用户广播。

第五章:在房间中分离通信

通道允许我们向属于一个组的所有客户端广播、发送和接收异步消息。在组内,我们无法筛选用户。为了解决这个问题并创建分区或类别,我们必须求助于创建新的通道并手动分组客户端。

到目前为止,我们可以与被隔离在通道中的客户或与连接到公共通道的所有客户进行通信。现在,是时候学习如何控制组/通道,以便根据需要在不同组之间分离和移动客户了。你甚至可以将同一客户同时分配到几个组中。例如,如果我们正在创建聊天,用户订阅一个独特的通道以接收通知,以及另一个所有客户都可以自由写作的公共组,还有其他私人组,他们可以在其中与其他用户进行对话,这很有意义。对于客户端来说,从不同的组以不同的目的接收或发送不同的消息是有意义的。

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

  • 管理通道的基本功能

  • 创建完整的聊天

技术要求

你可以从本书的 GitHub 仓库下载本章的代码:github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-5

我们将使用我们在第四章**,与数据库一起工作中构建的模板:github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/initial-template

我已将应用程序的名称更改为 Chat。确保App文件夹名为/app/chat/,并且apps.py已重命名,其name变量:

from django.apps import AppConfig
class SimpleAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "app.chat" # Update

如果你重命名了一个应用程序,你必须反映在/project_template/settings.py中:

INSTALLED_APPS = [
    "channels",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "app.chat", # Update
]

我也将消费者的名称更改为ChatConsumer

# app/chat/consumers.py
class ChatConsumer(JsonWebsocketConsumer):

project_template/urls.py中,你必须更改视图导入:

from app.chat import views

Caddyfile中,将域名从hello.localhost更改为chat.localhost

http://chat.localhost {
    root * /usr/src/app/

最后,请记住,每次你更改消费者的名称时,都必须修改/project_template/asgi.py

from app.chat.consumers import ChatConsumer # Update
... 
                    re_path(r"^ws/chat/$", ChatConsumer.
                        as_asgi()), # Update
...

在模板就绪后,我们现在可以开始项目,这将涉及创建一个聊天工具。

我们将使用模型准备数据库并生成一些随机用户。然而,在我们继续之前,我们必须了解通道为向客户发送信息或管理组提供的功能。

管理通道的基本功能

管理通道的基本功能如下:

  • send(): 这个函数用于从消费者向单个客户端发送新消息。我们从本书的开头就使用了这个函数。然而,我们使用了JsonWebsocketConsumer包装器来使send_json()在发送 JSON 时更加方便:

    data = {
                "my_data": "hi",
           }
    self.send_json(data)
    
  • group_send(): 这个函数用于从消费者向之前定义的客户端群组发送新消息。它是一个异步函数,因此我们需要整个消费者都是异步的,或者最好使用async_to_sync函数。在下面的示例中,你可以看到{"my_data": "hi"} JSON 是如何作为"Main"发送给整个群组的:

    from asgiref.sync import async_to_sync
    async_to_sync(self.channel_layer.group_send)(
                "Main", {
                    "type": "send.hi", # Run "send_hi()" 
                        method
                    "my_data": "hi",
                }
    
    def send_hi(self, event):
            """Event: Send "hi" to client""""
            data = {
                "my_data": event["my_data"],
            }
            self.send_json(data)
    
  • group_add(): 这个函数用于将客户端添加到新的或现有的群组中。该函数也是异步的,因此我们将再次使用async_to_sync。在下面的示例中,我们将(self.channel_name)添加到名为"Main"的群组中:

    async_to_sync(self.channel_layer.group_add)("Main", self.channel_name)
    
  • group_discard(): 这个函数用于从群组中移除一个客户端。同样,这也是一个异步函数,因此我们被迫使用async_to_sync。在这个示例中,我们已经从名为"Main"的群组中移除了(self.channel_name)

    async_to_sync(self.channel_layer.group_discard)("Main", self.channel_name)
    

通过这些功能,我们现在可以统治世界,或者至少是实时世界的世界。它们非常适合构建一个完整的聊天系统。而且...我们为什么不去与 WhatsApp 或 Slack 竞争呢?他们有成百上千位最优秀的工程师,但我们将在这里使用 Django:这是一场势均力敌的战斗。我们将创建一个软件,它将充分利用 Channels 的潜力来管理具有以下功能的多个群组:

  • 无限制客户端数量的群组和公开消息

  • 可以在两个客户端之间发送的私人消息

  • 控制已连接或断开连接的客户端

  • 可以识别注册用户

如果我们将 Channels 的功能与 Django 的功能结合起来,我们会发现我们拥有管理信息和连接到数据库所需的一切。然而,在连接到 Django 的模型之前,我们需要了解一些重要的细节。我们如何隔离用户?

创建一个完整的聊天系统

在任何技术中实现 WebSockets 时,一个非常流行的练习是创建一个简单的聊天系统。然而,当有多个连接的客户端将在私人空间和公开群组中交谈,并且任何客户端都可以阅读或参与时,难度会大大增加。使用 Channels,我们正在创建足够的抽象,以便我们可以专注于其他问题。

让我们创建一个具有现代功能的完整聊天系统:

  • 消息历史

  • 私人对话

  • 群组

  • 与数据库中注册用户关联的客户

接下来,我们必须定义数据库。我们将定义用户、房间和消息的模型。这样,我们就能存储每个用户的操作,并且会有关于所发生一切的记录。

定义数据库

在本节中,我们将创建一些数据库模型来管理客户、群组(我们将称之为房间)和消息。

使用以下内容编辑/app/chat/models.py

from django.db import models
from django.contrib.auth.models import User
class Client(models.Model):
    """
    Clients for users
    """
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    channel = models.CharField(max_length=200, blank=True, null=True, default=None)
    created_at = models.DateTimeField(auto_now_add=True)
    def __str__(self):
        return self.user.username

Client 模型允许我们记录已连接或断开连接的用户。它还允许我们存储每个客户端的私有频道,以防我们需要从代码中的任何位置向他们发送个人消息:

class Room(models.Model):
    """
    Rooms for users
    """
    users_subscribed = models.ManyToManyField(User, 
        related_name="users_subscribed")
    clients_active = models.ManyToManyField(Client, 
        related_name="clients_active")
    name = models.CharField(max_length=255, blank=True, 
        null=True, default=None)
    is_group = models.BooleanField(default=False)
    def __str__(self):
        return self.name

Rooms 将记录所有已创建的频道以及通过 users_subscribed 列订阅它们的客户端。我们必须执行此功能,因为频道不允许我们访问此信息,除非我们使用第三方扩展或数据库中创建记录,这正是我们在这里所做的事情。我们将使用 clients_active 来了解哪些客户端目前正在查看该组,因为它们可能被添加,但同时也可能断开连接或出现在另一个房间中。这样,我们只会将带有消息列表的更新或新 HTML 发送到活跃的客户端,而不是所有订阅的客户端。最后,name 将是组的名称,is_group 将标记这是一个有多个客户端的公共组(True)还是私人房间(False),这对于控制不受欢迎的访客是强制性的:

class Message(models.Model):
    """
    Messages for users
    """
    User = models.ForeignKey(User, on_delete=models.
        CASCADE)
    room = models.ForeignKey(Room, on_delete=models.
        CASCADE)
    text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    def __str__(self):
        return self.text

Message 模型将负责存储聊天消息。每个元素都将有一个作者(我们将称之为 user),一个消息已发送的频道(我们将称之为 room),以及消息本身的文本(我们将称之为 text)。此外,我们还添加了 created_at 以便在列出消息时对它们进行排序。

我们将启动 docker-compose 来执行迁移:

docker-compose up

在定义了模型之后,我们将创建迁移。我们需要进入 django 容器并查找其名称。作为一个提示,我们知道它将以 _django_1 结尾:

docker ps

您将看到所有活动容器的列表,以及它们正在运行的进程:

![图 5.1 – Docker 启动后列出所有容器的名称img/Figure_5.01_B18321.jpg

图 5.1 – Docker 启动后列出所有容器的名称

在我的情况下,Django 是 chapter-5_django_1

现在,让我们进入交互式 Bash 终端:

docker exec -it chapter-5_django_1 bash

在这里,我们可以创建必要的迁移:

./manage.py makemigrations chat
./manage.py migrate

数据库准备就绪后,我们将添加一些随机用户以区分客户端。

生成随机用户

没有注册用户,我们无法工作,所以让我们创建一个 Python 脚本来生成一些随机数据。

我们将在项目的根目录下创建一个名为 make_fake_users.py 的文件,其中包含以下内容。目前,我们无法运行它,因为我们尚未安装 Faker

Faker

Faker 是一个用于生成各种用途的假数据的 Python 库。在其最常见用途中,它用于将数据插入数据库以进行开发、原型设计或压力测试应用程序。

# make_fake_users.py
from django.contrib.auth.models import User
from faker import Faker
fake = Faker()
# Delete all users
User.objects.all().delete()
# Generate 30 random emails and iterate them.
for email in [fake.unique.email() for i in range(5)]:
    # Create user in database
    user = User.objects.create_user(fake.user_name(), 
        email, "password")
    user.last_name = fake.last_name()
    user.is_active = True
    user.save()

使用 Faker,我们可以生成五个唯一的电子邮件。然后,我们遍历它们,并创建一个具有生成的用户名和姓氏的唯一用户。

要安装 Faker,请将以下行添加到 requirements.txt 文件中:

# Fake data
Faker===8.13.2

不要忘记再次重建 Django 镜像,以便从Dockerfile安装新依赖项。

现在,让我们从 Django 容器中运行 Python 脚本:

./manage.py shell < make_fake_users.py

我们目前有五个随机用户可供使用。

在数据库创建并填充数据后,我们可以专注于生成将使用这些信息的 HTML 及其组件。

集成 HTML 和样式

我们需要显示一些简洁的 HTML,以便使聊天可用,尽管我们不会赢得年度最佳网页设计奖。

让我们创建app/chat/templates/index.html,内容如下:

{# app/chat/templates/index.html #}
{% load static %}
<! doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, 
        user-scalable=no, initial-scale=1.0, maximum-
            scale=1.0, minimum-scale=1.0">
    <title>Chat</title>
    {# CSS #}
    <link rel="stylesheet" href="{% static 'css/main.css' %}">
    {# JavaScript #}
    <script defer src="img/index.js' %}">
    </script>
</head>

让我们链接未来的 CSS 和 JavaScript 文件:

<body
        data-host="{{ request.get_host }}"
        data-scheme="{{ request.scheme }}"
>

现在,让我们沟通 JavaScript 将使用的路径来连接到host,并使用scheme检查连接是否安全:

    <h1 class="title">Chat</h1>
    {# Login user name #}
    <h2 class="subtitle">I'm <span id="logged-user">
    </span></h2>
    <div class="container chat">
        <aside id="aside">
            {# List of groups and users #}
            {% include "components/_aside.html" with 
            users=users %}
        </aside>
        <main id="main">
            {# Chat: Group name, list of users and form to 
            send new message #}
            {% include "components/_chat.html" %}
        </main>
    </div>
</body>
</html>

上述代码块分为三个部分:

  • <span id="logged-user"></span>: 用于显示客户端的名称

  • <aside id="aside"></aside>: 一个组件,将列出可点击以动态跳转到频道(或房间)的组名和用户名

  • <main id="main"></main>: 包含另一个组件,用于渲染所有现有或新消息,并使用相应的表单发布新消息

现在,让我们创建所有组件。让我们从/app/chat/templates/components/_aside.html开始:

<nav>
    {# Group links #}
    <h2>Groups</h2>
    <ul class="nav__ul">
        <li class="nav__li">
            <a
                class="nav__link"
                href="#"
                data-group-name="hi"
                data-group-public="true"
            >
                #hi
            </a>
        </li>
        <li class="nav__li">
            <a
                class="nav__link" 
                href="#"
                data-group-name="python"
                data-group-public="true"
            >
                #python
            </a>
        </li>
                <li class="nav__li">
            <a
                class="nav__link"
                href="#"
                data-group-name="events"
                data-group-public="true"
            >
                #events
            </a>
        </li>
        </li>
        <li class="nav__li">
            <a
                class="nav__link" 
                href="#"
                data-group-name="off-topic"
                data-group-public="true"
            >
                #off-topic
            </a>
        </li>
    </ul>
    {# End Group links #}
    {# Users links #}
    <h2> Users</h2>
    <ul class="nav__ul">
    {% for user in users %}
        <li class="nav__li">
            <a
                class="nav__link"
                href="#"
                data-group-name="{{ user.username }}"
                data-is-group="false"
            >
                {{ user.username }}
            </a>
        </li>
    {% endfor %}
    </ul>
    {# End Users links #}
</nav>

为了简化此代码,我们已手动输入所有组的名称,其中多个客户端可以同时发言。您可以从模型中生成它们。

现在,让我们创建/app/chat/templates/components/_chat.html

<section class="messages">
    {# Name of the connected group #}
    <h2 id="group-name">{{ name }}</h2>
    {# List of messages #}
    <div class="messages__list" id="messages-list"></div>
    {# Form to add a new message #}
    <form action="" class="messages__new-message">
        <input type="text" class="input" name="message" 
            id="message-text" />
        <input type="submit" id="send" class="button" 
            value="Send" />
    </form>
</section>

上述代码包含任何自重尊聊天室的基本三个部分:

  • 当前存在的组或频道名称

  • 消息列表

  • 用于添加新消息的表单

然而,消息列表是空的。HTML 模板中的循环在哪里?为了整洁,我们将它放置在另一个组件中,该组件位于app/chat/templates/components/_list_messages.html,其中包含以下代码:

{% for message in messages %}
    {# Item message #}
    <article class="message__item">
        <header class="massage__header">
            {# Username #}
            <h3 class="message__title">{{ 
                message.user.username }}</h3>
            {# Date of creation #}
            <time class="message__time">{{ 
                message.created_at|date: "d/m/Y H:i" 
                    }}</time>
        </header>
        <div>
            {# Text #}
            {{ message.text }}
        </div>
    </article>
    {# End Item message #}
{% endfor %}

现在我们已经定义了所有聊天 HTML,我们只需要添加一些最小样式来给它结构。

定义 CSS 样式

在本节中,我们将在static/css/main.css中创建一个样式文件,其中包含一些修复,以使未来的聊天更易于使用:

/* Global styles */
:root {
    --color__background: #f6f4f3;
    --color__gray: #ccc;
    --color__black: #000;
}
* {
    font-family: "Helvetica Neue", Helvetica, Arial, sans-
        serif;
    box-sizing: border-box;
}
body {
    margin: 0;
    background-color: var(--color__background);
}

我们将准备一些颜色,提供漂亮的字体(如果您只从这本书中取一样东西,请始终使用 Helvetica),并安排body

/* General classes for small components */
.container {
    margin: 0 auto;
    padding: 1rem 0;
    max-width: 40rem;
}
.button {
    display: inline-block;
    padding: 0.5rem 1rem;
    background-color: var(--color__gray);
    border: 0;
    cursor: pointer;
    text-decoration: none;
}
.button:hover {
    filter: brightness(90%);
}
.input {
    display: block;
    width: 100%;
    outline: none;
    padding: .5rem;
    resize: none;
    border: 1px solid var(--color__gray);
    box-sizing: border-box;
}

我们将稍微现代化输入,并准备一个容器以居中聊天:

/* Styles for chat */
.title {
    text-align: center;
}
.subtitle {
    text-align: center;
    font-weight: normal;
    margin: 0;
}
.chat {
    display: grid;
    grid-template-columns: 1fr 3fr;
    gap: 1rem;
}

现在,让我们水平对齐<aside><main>

/* Aside */
.nav__ul {
    list-style: none;
    padding: 0;
}
.nav__link {
    display: block;
    padding: 0.5rem 1rem;
    background-color: var(--color__gray);
    border: 1px solid var(--color__background);
    color: var(--color__black);
    text-decoration: none;
}
.nav__link:hover {
    filter: brightness(90%);
}
/* End Aside */

在这里,我们已固定浏览器和包含在<aside>内的链接,以便它们有一个足够舒适的点击区域:

/* Chat */
.messages {
    display: grid;
    height: 30rem;
    grid-template-rows: 4rem auto 2rem;
}
.massage__header {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-gap: 1rem;
}
.messages__list {
    overflow-y: auto;
}
.message__item {
    border: 1px solid var(--color__gray);
    padding: 1rem;
}
.massage__header . message__title {
    margin-top: 0;
}
.massage__header . message__time {
    text-align: right;
}
.messages__new-message {
    display: grid;
    grid-template-columns: 8fr 1fr;
}
/* End Chat */

最后,我们将每个聊天消息转换为一个带有边框的清晰分隔的框。我们还水平对齐了输入和表单按钮,以便像今天一样显示。

现在,我们必须创建一个视图来渲染我们已创建的所有部分——数据库、生成的用户、模板和 HTML 组件——以及一点 CSS。

创建视图

目前聊天根目录下还没有任何内容。没有视图和路由,模板无法被提供给客户端。即使我们展示一个静态模板,我们也必须指明可以访问和渲染它的路径。我们需要一个视图来生成其展示的 HTML。

/app/chat/views.py中,我们将创建一个名为index的视图,它渲染index.html,显示所有用户,这些用户将在<aside>中显示:

from django.shortcuts import render
from django.contrib.auth.models import User
def index(request):
    """View with chat layout"""
    return render(
        request, "index.html", { "users": 
            User.objects.all(). order_by("username")})

/project_template/urls.py中,我们将添加当访问者进入网站根目录时要显示的视图:

from django.urls import path
from app.chat import views
urlpatterns = [
    path("", views.index, name="index"),
]

现在,我们将打开我们手头的浏览器,访问项目的域名。地址在docker-compose.yaml文件的DOMAIN变量中描述。如果你没有修改文档,地址将是http://hello.localhost。在我的情况下,我已经将其更改为http://chat.localhost

我们将在浏览器中看到手动编写的群组列表和现有用户的列表。此外,我们还有一个可以写入未来消息的表单:

![图 5.2 – 没有任何消息、群组名称或客户端名称的聊天图片 5.02 – 根目录下没有任何内容

图 5.2 – 没有任何消息、群组名称或客户端名称的聊天

视觉部分已经准备好了;现在,我们可以将所有注意力集中在聊天逻辑上。我们已经有了主体;现在,我们需要一个大脑来管理逻辑。

声明 JavaScript 以监听或发送消息

让我们更新static/js/index.js文件,添加以下代码:

    VARIABLES
*/
// Connect to WebSockets server (SocialNetworkConsumer)
const myWebSocket = new WebSocket(`${document.body.dataset.scheme === 'http' ? 'ws' : 'wss'}://${ document.body.dataset.host }/ws/chat/`);

我们将使用数据集的<body>标签中打印的schemehost值,通过 WebSockets 客户端连接到后端:

    FUNCTIONS
*/
/**
* Send data to WebSockets server
* @param {string} message
* @param {WebSocket} webSocket
* @return {void}
*/
function sendData(message, webSocket) {
    webSocket.send(JSON.stringify(message));
}

让我们检索之前示例中使用的sendData()函数,用于向后端发送消息:

/**
* Send message to WebSockets server
* @return {void}
*/
function sendNewMessage(event) {
    event.preventDefault();
    const messageText = document.querySelector('#message-
        text')
    sendData({
            action: 'New message',
            data: {
                message: messageText.value
            }
        }, myWebSocket);
    messageText.value = '';
}

现在,我们必须声明一个发送新消息的函数。由于我知道作者是谁,所以除了文本之外我们不需要任何东西:

/**
* Requests the Consumer to change the group with respect to the Dataset group-name.
* @param event
*/
function changeGroup(event) {
    event.preventDefault();
    sendData({
            action: 'Change group',
            data: {
                groupName: event.target.dataset.groupName,
                isGroup: event.target.dataset.groupPublic 
                    === "true".
            }
        }, myWebSocket);
}

changeGroup()函数将告诉 Consumer 更改群组,并发送现有群组消息的 HTML。我们将伴随这个请求,提供存储要更改的房间名称和是否是多用户群组或私密对话的信息的数据集。

最终的 JavaScript 片段是用于后端监听器:

    EVENTS
*/
// Event when a new message is received by WebSockets
myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    document.querySelector(data.selector).innerHTML = 
        data.html;

如前例所示,我们将收集 JSON,解析它,并注入 HTML:

    // Scrolls to the bottom of the chat
    const messagesList = document.querySelector('#messages-
        list');
    messagesList.scrollTop = messagesList.scrollHeight;

每次我们打印消息列表或接收新消息时,滚动条都会放置在不适当的高度。它可能根本不会滚动,或者它可能悬挂在中间。为了解决这个问题,在每次 HTML 注入后,我们必须滚动到元素的末尾,始终显示最后一条消息。这是所有聊天中常见的操作:

    /**
     * Reassigns the events of the newly rendered HTML
     */
    // Button to send new message button
    document.querySelector('#send').addEventListener('click', 
    sendNewMessage);
    // Buttons for changing groups
    document.querySelectorAll(".nav__link").forEach(button => {
        button.addEventListener("click", changeGroup);
    });
});

最后,我们必须在每次渲染后重新分配所有事件。发送新消息的按钮(ID 为send)将执行sendNewMessage(),而所有<aside>按钮将调用changeGroup()

前端定义完成后,现在是时候与消费者(Consumer)一起工作了。消费者负责管理数据库、监听 JavaScript、渲染 HTML 以及管理群组。

构建消费者以管理群组

在本节中,我们将定义当客户端连接、断开连接、发送更改群组操作或添加新消息时会发生什么。

使用以下内容编辑app/chat/consumers.py

# app/chat/consumers.py
from channels.generic.websocket import JsonWebsocketConsumer
from django.template.loader import render_to_string
from asgiref.sync import async_to_sync
from channels.auth import login, logout
from django.contrib.auth.models import User
from .models import Client, Room, Message

让我们导入认证系统、用户和模型:

class ChatConsumer(JsonWebsocketConsumer):

当我们加载消费者时,我们将首先删除僵尸客户端,以防我们强制关闭 Django:

    Client.objects.all().delete()
    def connect(self):
        """Event when client connects"""

现在,我们将接受客户的连接:

        self.accept()

接下来,我们将获取一个尚未注册为客户的随机用户:

        user = User.objects.exclude(
            id__in=Client.objects.all().values("user")
        ).order_by("?").first()

在这里,我们将识别用户。与存储用户 ID 相比,使用会话将更容易工作:

        async_to_sync(login)(self.scope, user)
        self.scope["session"].save()

现在,我们将向前端发送注册用户的名称:

        self.send_html(
            {
                "selector": "#logged-user",
                "html": self.scope["user"].username,
            }

接下来,我们将客户端注册到数据库中以控制谁可以连接:

        Client.objects.create(user=user, 
            channel=self.channel_name)

在这一点上,我们将把"hi"群组指定为进入时首先显示的房间。我们创建了一个特殊函数来处理更改房间时的重复性任务。我们将在稍后描述该函数的工作原理:

        self.add_client_to_room("hi", True)

现在,让我们列出我们刚刚分配给客户端的房间的消息:

        self.list_room_messages()
def disconnect(self, close_code):
        """Event when client disconnects"""

当客户端断开连接时,我们将执行以下三个任务:

  • 从当前房间中移除客户端:
        self.remove_client_from_current_room()
  • 注销客户端:
        Client.objects.get(channel=self.channel_name).delete()
  • 注销用户:
        logout(self.scope, self.scope["user"])

通过这样,我们自动实现了一个系统,为用户创建会话,这对于识别和向用户发送个人消息非常有用,并且在 WebSocket 客户端断开连接时关闭用户的会话。

我们在其他示例中用于管理前端操作的功能在这里也很有用。后端任务很简单:监听并返回 JSON。我们将始终使用相同的函数,无论应用程序如何:

    def receive_json(self, data_received):
        """
            Event when data is received
            All information will arrive in 2 variables:
            "action", with the action to be taken
            "data" with the information
        """
        # Get the data
        data = data_received["data"]

根据操作,我们将执行一项或多项任务。这些是前端请求的操作,例如添加新消息或列出所有消息。

只有当前端发起请求时,我们才会更改群组。但这个请求何时会发起?当用户点击他们想要进入的群组名称或他们想要交谈的用户时。这个事件将被前端捕获,并将“更改群组”操作发送到后端。

对于想要进入私人房间(只有两个用户)的用户和想要进入公共房间(对用户数量和开放消息没有限制)的用户,我们不能以同样的方式工作。代码是不同的。为了控制这种情况,我们将要求前端发送我们isGroup。如果是true,则是一个公共群组。如果是false,则是两个用户之间的私人群组。

我们将首先更改组:

        match data_received["action"]:
            case "Change group":
                if data["isGroup"]:

如果 isGroupTrue,我们将客户端添加到多用户房间:#hi, #python...

                    self.add_client_to_room(data["groupName"], data["isGroup"])
                else:

如果 isGroupFalse,我们将添加目标用户和当前用户到私人房间。

我们面临的主要问题是当两个客户端需要互相交谈时,我们需要确保只为他们创建一个房间。困难之处在于我们需要检查房间是否已经存在,如果不存在,我们需要创建一个群组,并在他们想要连接时通知参与者。我们将不得不制作一个如下所示的决策树:

  1. 搜索一个已经创建的房间,其中两个客户端在过去已经交谈过。如果存在,检索房间的名称并将客户端添加到群组。如果不存在,转到 步骤 2

  2. 检查想要互相交谈的用户是否独自在一个房间里。这是因为他们已经创建了一个房间,并正在等待另一个用户加入并与之交谈。如果不是,请转到 步骤 3

  3. 创建一个新的房间,并希望有用户想要与我们交谈。

首先,我们将搜索两个客户端匹配的房间:

                    room = Room.objects.filter(users_subscribed__in=[self.scope["user"]], is_group=False). intersection(Room.objects.filter(users_subscribed__in=[user_target], is_group=False)).first())
                    if room and user_target and room.users_subscribed.count() == 2:

然后,我们将获取想要交谈的客户端:

                    user_target = User.objects.filter(username=data["groupName"]).first()

可能找到一个现有的群组,其中目标和当前客户端已经在交谈。这是最有利的情况,因为有一个先前的对话,其中已经创建了一个房间。在这种情况下,客户端可以被添加到群组中进行交谈:

                        self.add_client_to_room(room.name)
                    else:

如果没有现有的群组,寻找目标用户独自一人的房间:

                        room = Room.objects.filter(
                            users_subscribed__in=[
                                user_target,
                            ],
                            is_group=False,
                        ).last()
                        if room and room.users_subscribed.count() == 1:

如果有房间,让我们加入:

                            self.add_client_to_room(room.name)
                        else:

如果我们没有找到目标用户独自一人的房间,我们必须创建一个新的房间:

                            self.add_client_to_room()

将客户端移动到另一个房间后,我们需要给他们反馈,以便他们知道他们此刻所在的房间。我们将发送房间名称给他们:

                self.send_room_name()
            case "New message":

在这里,我们收到了一条需要保存的新消息:

                self.save_message(data["message"])

之后,我们将向客户端展示一些更改,例如添加一条新消息。我们总是会发送客户端所在位置的短信列表,以便他们能够获得最新的 HTML 更改:

        self.list_room_messages()
    def send_html(self, event):
        """Event: Send html to client"""
        data = {
            "selector": event["selector"],
            "html": event["html"],
        }
        self.send_json(data)
    def list_room_messages(self):
        List all messages from a group""""""
        room_name = self.get_name_room_active()
        # Get the room
        room = Room.objects.get(name=room_name)
        # Get all messages from the room
        messages = Message.objects.filter(room=room). order_by("created_at")
        # Render HTML and send to client
        async_to_sync(self.channel_layer.group_send)(
            room_name, {
                "type": "send.html", # Run "send_html()" method
                "selector": "#messages-list",
                "html": render_to_string("components/_list_messages.html", {"messages": messages})
            }
    def send_room_name(self):
        """Send the room name to the client"""
        room_name = self.get_name_room_active()
        room = Room.objects.get(name=room_name)
        data = {
            "selector": "#group-name",
            # Concadena # if it is a group for aesthetic reasons
            "html": ("#" if room.is_group else "") + room_name,
        }
        self.send_json(data)

每当我们想知道自己是谁时,我们可以使用 self.scope["user"]。它将返回已登录的用户对象:

    def save_message(self, text):
        "Save a message in the database"""""
        # Get the room
        room = Room.objects.get(name=self.get_name_room_active())
        # Save message
        Message.objects.create(
            user=self.scope["user"],
            room=room,
            text=text,

要将用户添加到房间中,我们必须执行以下操作:

  1. 获取用户客户端。

  2. 从之前的房间中移除客户端。

  3. 获取或创建一个房间。

  4. 如果房间没有名称,则分配 "private_{id}"。例如,如果 id 是 1,它应该是 "private_1"

  5. 将客户端添加到群组中。

  6. 将群组名称发送给客户端,如下所示:

    def add_client_to_room(self, room_name=None, is_group=False):
        """Add customer to a room within Channels and save the reference in the Room model."""""
        client = Client.objects.get(user=self.scope["user"])
        self.remove_client_from_current_room()
        room, created = Room.objects.get_or_create(name=room_name, is_group=is_group)
        if not room.name:
            room.name = f "private_{room.id}"
            room.save()
        room.clients_active.add(client)
        room.users_subscribed.add(client.user)
        room.save()
        async_to_sync(self.channel_layer.group_add)(room.name, self.channel_name)
        self.send_room_name()

让我们更详细地描述前面的代码。有几个重要的部分需要理解:

  1. 通过过滤数据库,获取我们活跃的房间名称相对容易:

        def get_name_room_active(self):
            """Get the name of the group from login user""""
            room = Room.objects.filter(clients_active__user_id=self.scope["user"].id). first()
            return room.name
    
  2. 要将自己从群组中移除,我们必须执行相反的操作:

        def remove_client_from_current_room(self):
            Remove client from current group""""""
    
  3. 我们获取我们活跃的所有房间:

            client = Client.objects.get(user=self.scope["user"])
            rooms = Room.objects.filter(clients_active__in=[client])
    
  4. 我们进出房间并相互消除:

            for room in rooms:
    
  5. 从群组中移除客户端:

                async_to_sync(self.channel_layer.group_discard)(room.name, self.channel_name)
    
  6. 我们从房间模型中移除客户端:

                room.clients_active.remove(client)
                room.save()
    

现在聊天已经完成。当我们进入时,它将渲染我们的用户名和我们在活跃的房间名称。在聊天的开始,我们将看到 #hi

![图 5.3 – 我们的用户名,在登录时随机分配,以及当前房间图片

图 5.3 – 我们的用户名,在登录时随机分配,以及当前房间

如果我们在另一个浏览器中打开一个标签页或使用私密浏览,将为会话分配一个新的随机用户,并且我们能够向任何群组发布帖子。所有消息都将实时渲染到当前群组中或活跃的客户端:

![图 5.4 – 任何用户都可以在群组中自由写作,没有限制图片

图 5.4 – 任何用户都可以在群组中自由写作,没有限制

如果我们打开第三个浏览器,我们可以体验私人房间或两个客户端之间的对话:

![图 5.5 – 两个用户之间的私密对话图片

图 5.5 – 两个用户之间的私密对话

在任何时候,我们都可以与现有用户或群组交换消息。此外,由于我们有一个数据库来存储消息,即使我们重新启动 Docker 容器,我们也会看到所有已写入的历史记录,按创建日期排序。这里,我们有一个真实的聊天,具有后端实时响应和逻辑。如果我们知道如何使用 Django 的原生工具以及如何管理 Channels,我们能实现的事情真是令人惊叹。

摘要

在本章中,我们创建了一个具有私人房间和群组的实用聊天,类似于 Slack 或 Teams 等其他软件,只需很少的 JavaScript 代码(没有注释,少于 35 行)。此外,我们在认证系统中迈出了第一步。我们现在可以在不同的 Channels 中注册和管理客户端,根据我们的需求,并知道谁已连接或断开连接。魔法结束了——我们现在已经是 Channels 的主人了。

在下一章,第六章在后端创建 SPAs,我们将处理使网站动态化的最后几个必要元素,例如更改页面、决定何时更新整个部分或添加新的 HTML 片段、使用会话以减少对数据库的依赖,以及验证数据的来源以避免使用 WebSockets 的跨站请求伪造(CSRF)。凭借我们已掌握的所有技能,我们将在 第七章仅使用 Django 创建实时博客 中构建一个完整的 SPA。

第三部分:通过 WebSocket 使用 HTML

在本部分,我们将吸收一种现代架构,用于使用后端渲染 HTML 并通过 WebSocket 连接将其发送到前端。我们将利用我们所学的所有知识来创建一个实时 SPA 博客,其中所有负载和逻辑都将位于后端,而前端只需负责处理事件。

在本部分,我们将涵盖以下章节:

  • 第六章,在后台创建 SPA

  • 第七章,仅使用 Django 创建实时博客

第六章:在后端创建 SPAs

我们不能仅仅通过管理组和向客户端发送 HTML 来创建一个完整的网站。我们必须首先掌握各种小型解决方案,以便能够构建一个与用户交互的动态页面,具有页面切换等基本功能!

当最初的单页应用(SPAs)被创建时,当时的开发者被迫花费许多小时在 HTTP 协议使用时免费的那些功能上:路由、会话、身份验证或源验证等。可怜的他们!他们不得不使用叛逆的青少年 JavaScript 重新发明轮子,而这种 JavaScript 并不非常跨浏览器兼容。然而,他们幸存了下来,或者至少我希望如此,通过在前端定义技术,这些技术成功地模仿了 HTTP 的行为;这些技术一直持续到今天。例如,在一个路由系统中,当 SPA 重绘屏幕时,浏览器 URL 会被修改以将用户置于上下文中。另一方面,如果访客手动输入地址,应用程序会通过加载构成屏幕的组件来做出反应。所有这些任务都使用 JavaScript 实现起来非常耗时。不发送新请求就更改内容并不便宜。如果我们只是使用纯 HTML,我们就不需要做任何事情,但当然,用户会每次更改时都经历页面刷新。这一切与我们有什么关系呢?如果我们使用 WebSockets 协议创建页面,我们会发现自己处于类似的情况;我们必须发明公式来模拟礼貌用户期望我们提供的行为。

与其他库相比,Channels 在功能上简单,但同时也非常成熟且与现实世界保持一致。它是一个因需求而生的框架。它依赖于 Django 为我们提供解决典型问题的基本要素,同时提供灵活性。

在本章中,我们将回顾以下不同方法:

  • 在页面之间切换

  • 为每个路由进行服务器端渲染

  • 包含浏览器以实现动态导航

  • 更改 URL

  • 润滑部分或组件

  • 为会话创建临时会话

  • 使用 WebSockets 避免跨站请求伪造CSRF

因此,我们将专注于如何解决每个问题,以便为准备在第七章,“仅使用 Django 创建实时博客”做准备。

让我们更好地组织我们的项目。从现在开始,我们将把 Channels 分成两个文件:consumers.py,它将是views.py或前端和后端之间的通信网关,以及actions.py,其中将放置逻辑或函数。

我们将从添加一个完整的页面切换系统开始。你不需要按顺序遵循每个点,因为你会找到我们如何解决每个任务的示例,而不是一个教程。

技术要求

所有不同部分的代码都可以在以下链接中找到:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-6

在页面之间切换

在某个时候,用户将需要转到另一个页面或更改上下文。我们将让他们认为这是在发生,但实际上,这将会是一个魔术,因为实际上,他们永远不会从我们最初给他们的第一个 HTML 页面移动。然而,关键在于,他们会感觉到页面正在改变。为了实现这种欺骗(抱歉,成就),我们将执行以下任务:

  1. 更改主内容或属于<main>的任何内容的 HTML。同时,我们将始终保留页面的静态部分,如<header><aside><footer>

  2. 实现服务器端渲染以渲染属于每个 URL 的 HTML。

  3. 使用 CSS 样式在<nav>中视觉标记我们的位置。

  4. 通过 JavaScript API 修改浏览器 URL。这是一个美学上的改变,但 URL 充当面包屑以引导访客。

目标是建立一个包含三个页面的网站:base.html

{% load static %}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, 
        user-scalable=no, initial-scale=1.0, maximum-
            scale=1.0, minimum-scale=1.0">
    <title>Example website</title>
    <link rel="stylesheet" href="{% static 'css/main.css' 
        %}">
    <script defer src="img/index.js' %}">
    </script>
</head>
<body
        data-host="{{ request.get_host }}"
        data-scheme="{{ request.scheme }}">
    <div class="container">
        <header>
            <nav id="nav" class="nav">{% include 
                'components/_nav.html' %}</nav>
        </header>
        <main id="main">{% include page %}</main>
        <footer class="footer">My footer</footer>
    </div>
</body>
</html>

components/_nav.html组件将在我们讨论导航时再进行讨论。重要的是,我们将在<main>内部嵌入一个include,我们将使用它来创建未来的服务器端渲染系统。

接下来,在Consumer类中,我们将创建"Change page"动作,该动作将在actions.py内部调用send_page (self, "page name")函数:

# app/app_template/consumers.py
from channels.generic.websocket import JsonWebsocketConsumer
import app.app_template.actions as actions
class ExampleConsumer(JsonWebsocketConsumer):
    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept() 
    def disconnect(self, close_code):
        """Event when client disconnects"""
        pass
    def receive_json(self, data_received):
        """
            Event when data is received
            All information will arrive in 2 variables:
            "action", with the action to be taken
            "data" with the information
        """
        # Get the data
        data = data_received["data"]
        # Depending on the action we will do one task or another.
        match data_received["action"]:
            case "Change page":
                actions.send_page(self, data["page"]) 
    def send_html(self, event):
        """Event: Send html to client"""
        data = {
            "selector": event["selector"],
            "html": event["html"],
            "append": "append" in event and event["append"],
            "url": event["url"] if "url" in event else "",
        }
        self.send_json(data)

如您可能已经注意到的,send_html也已经修改,以包含append,我们将使用它来指示我们是否想要向选择器添加一个 HTML 块或替换所有内容(目前我们不会实现它),而url将用于指示将在浏览器中显示的 URL。

app/app_template/actions.py中,我们将定义一个渲染 HTML 并将其发送到前端的功能:

from .forms import LoginForm, SignupForm
from asgiref.sync import async_to_sync
from django.template.loader import render_to_string
from django.urls import reverse
from datetime import datetime
def send_page(self, page):
    """Render HTML and send page to client""""
    # Prepare context data for page
    context = {}
    match page:
        case "login":
            context = {"form": LoginForm()}
        case "signup":
            context = {"form": SignupForm()}
     context.update({"active_nav": page})

我们准备将用于渲染 HTML 模板的变量,每个页面对应的Form对象,以及我们所在页面的名称:

    # Render HTML nav and send to client
    self.send_html({
        "selector": "#nav",
        "html": render_to_string("components/_nav.html", 
            context),
    })

在每次页面更改时,我们必须重新绘制main浏览器以标记我们的位置:

    # Render HTML page and send to client
    self.send_html({
        "selector": "#main",
        "html": render_to_string(f"pages/{page}.html", 
            context),
        "url": reverse(page),
    })

最后,我们使用名为url的变量将页面的 HTML 发送到<main>前端。这将在稍后由 JavaScript 用于修改浏览器的地址。

在我们继续实现页面切换之前,让我们停下来,使用 Django 实现每个视图的渲染。这将简化创建浏览器以在页面之间移动的任务。

为每个路由实现服务器端渲染

在准备好Consumer类以动态更改页面后,我们将使用 Django 引入一个简单的系统来管理路由和渲染每个页面,而不依赖于 Channels,以便爬虫可以索引内容。我们将定义三个模板(home.htmllogin.htmlsignup.html)。

app/app_template/templates/pages/home.html的内容将是几行 HTML:

<section>
    <h1>Welcome to an example of browsing with WebSockets over the Wire.</h1>
    <p>You will be able to experience a simple structure. </p>
</section>

然后,在第二页,代表登录表单,我们将使用form对象列出所有字段并进行验证。这将是我们渲染模板时传递的参数。

我们在app/app_template/templates/pages/login.html中编写以下代码:

<h1>Login</h1>
<form id="login-form">
    {{ form.as_p }}
    <input type="submit" class="button" value="Login">
</form>

最后,我们在app/app_template/templates/pages/signup.html中使用form对象重复相同的结构:

<h1>Signup</h1>
<form id="signup-form">
    {{ form.as_p }}
    <input type="submit" class="button" value="Signup">
</form>

在定义视图之前,我们需要构建表单。在app/app_template/forms.py中,我们添加以下内容:

from django import forms
class LoginForm(forms.Form):
    email = forms.CharField(
        label="Email",
        max_length=255,
        widget=forms.EmailInput(attrs={"id": "login-email", 
            "class": "input"}),
    )
    password = forms.CharField(
        label="Password",
        max_length=255,
        widget=forms.PasswordInput(attrs={"id": "login-
            password", "class": "input"}),
    )
class SignupForm(forms.Form):
    username = forms.CharField(
        label="Username",
        max_length=255,
        widget=forms.TextInput(attrs={"id": "signup-
            username", "class": "input"}),
    )
    email = forms.EmailField(
        label="Email",
        max_length=255,
        widget=forms.EmailInput(attrs={"id": "signup-
            email", "class": "input"}),
    )
    password = forms.CharField(
        label="Password",
        max_length=255,
        widget=forms.PasswordInput(attrs={"id": "signup-
            password", "class": "input"}),
    )
    password_confirm = forms.CharField(
        label="Confirm Password",
        max_length=255,
        widget=forms.PasswordInput(
            attrs={"id": "signup-password-confirm", 
                "class": "input"}
        ),
    )

准备好要渲染的模板和表单后,我们编辑app/app_template/views.py

from django.shortcuts import render
from .forms import LoginForm, SignupForm 
def home(request):
    return render(
        request,
        "base.html",
        {
            "page": "pages/home.html",
            "active_nav": "home",
        },
    )
def login(request):
    return render(
        request,
        "base.html",
        { "page": "pages/login.html", "active_nav":
            "login", "form": LoginForm()},
                 )
def signup(request):
    return render(
        request,
        "base.html",
        { "page": "pages/signup.html", "active_nav": "signup", "form": SignupForm()},
    )

在所有情况下,我们使用base.html作为主布局,其中我们将使用page变量更改<main>的内容:

<main id="main">{% include page %}</main>

active_nav变量是一个视觉资源,通过改变适当超链接的颜色来通知访客他们所在的位置。我们暂时可以忽略它。

现在,我们编辑project_template/urls.py以定义所有路径:

from django.contrib import admin
from django.urls import path
from app.app_template import views
urlpatterns = 
    path("", views.home, name="home"),
    path("login/", views.login, name="login"),
    path("signup/", views.signup, name="signup"),
    path("admin/", admin.site.urls),

没有什么异常;这是 Django 自己的路由系统。除了一个细节:我们从未对其进行扩展。通常的方法是渲染home.html而不是base.html。换句话说,home.html是页面的内容,它使用base.html作为其结构:

{% extends 'base.html' %}
<section>
    <h1>Welcome to an example of browsing with WebSockets over the Wire</h1>.
    <p>You will be able to experience a simple structure.</p>
</section>

我们这样做是因为 Django 必须适应我们通过 WebSockets 绘制 HTML 的方式。我们只对修改<main>感兴趣,模板必须作为组件隔离以这种方式工作。

您现在可以打开三个路径,看看它们在不使用Consumer类的情况下是如何渲染的。

我们可以看到网站根目录的渲染方式:

![图 6.1 – 使用 Django 渲染主页![图 6.1 – 图 6.01_B18321.jpg 图 6.1 – 使用 Django 渲染主页登录表单渲染没有问题:![图 6.2 – 使用 Django 渲染登录页图 6.2 – 图 6.02_B18321.jpg

图 6.2 – 使用 Django 渲染登录页

当我们渲染注册页面时,情况相同:

![图 6.3 – 使用 Django 渲染注册页图 6.3 – 图 6.03_B18321.jpg

图 6.3 – 使用 Django 渲染注册页

在服务器端渲染系统就绪后,我们将引入一个浏览器来执行操作以动态修改页面主体或其小部分。

包括浏览器以实现动态导航

在引入模板、视图和传统导航的路由后,我们将创建一个动态导航系统。

我们在app/app_template/components/_nav.html路径中声明一个文件,内容如下:

<ul class="nav__ul">
    <li>
        <a
                href="#"
                class="nav__link nav__link nav__link--
                    page{% if active_nav == "home" %} 
                        active{% endif %}"
                data-target="home"
        >
            Home
        </a>
    </li>
    <li>
        <a
                href="#"
                class="nav__link nav__link--page{% if 
                active_nav == "login" %} active{% endif %}"
                data-target="login"
        >
            Login
        </a>
    </li>
    <li>
        <a
                href="#"
                class="nav__link nav__link nav__link--   
                    page{% if active_nav == "signup" %} 
                        active{% endif %}"
                data-target="signup"
        >
            Signup
        </a>
    </li>
</ul>

我们将传递 active_nav 到模板中,带有我们想要用 CSS 标记的页面名称,添加 active 类。另一方面,data-target 是一个数据集,它将收集要发送到 Consumer 类的 JavaScript,并告诉它要渲染哪个页面。

在 JavaScript 中,我们将为每个 <a> 分配一个 click 事件,将更改所需页面的动作发送到 Consumer 类。哪个页面?我们在 data-target 中保存的那个页面。在添加新事件监听器之前,我们必须小心,强烈建议我们删除之前的监听器以避免将事件重复发送到同一函数。记住,HTML 已经交换,但 JavaScript 保持静态。

编辑 static/js/index.js,添加浏览器事件:

/**
* Send message to update page
* @param {Event} event
* @return {void}
*/
function handleClickNavigation(event) {
    event.preventDefault();
    sendData({
        action: 'Change page',
        data: {
            page: event.target.dataset.target
        }
    }, myWebSocket);
}
/**
* Send message to WebSockets server to change the page
* @param {WebSocket} webSocket
* @return {void}
*/
function setEventsNavigation(webSocket) {
    // Navigation
    document.querySelectorAll('.nav__link--
        page').forEach(link => {
        link.removeEventListener('click', 
            handleClickNavigation, false);
        link.addEventListener('click', 
            handleClickNavigation, false);
    });
}
// Event when a new message is received by WebSockets
myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    const selector = document.querySelector(data.selector);
    selector.innerHTML = data.html;
    /**
     * Reassigns the events of the newly rendered HTML
     */
    updateEvents();
});
/**
* Update events in every page
* return {void}
*/
function updateEvents() {
    // Nav
    setEventsNavigation(myWebSocket);
}
    INITIALIZATION
*/
updateEvents();

现在,我们只需要在 static/css/main.css 中添加一些 CSS 来改变我们所在位置的链接颜色:

.nav__link.active {
    color: var(--color__active);
    text-decoration: none;
}

现在,我们可以切换页面,尽管这不会在浏览器的地址栏中反映出来。

![图 6.4 – 加载了动态在首页和注册页之间导航功能的登录页面图片

图 6.4 – 加载了动态在首页和注册页之间导航功能的登录页面

我们已经构建了一个可以在页面之间导航的网站,并且集成了传统的渲染来向搜索引擎蜘蛛提供内容。然而,我们没有向访客提供反馈。下一个目标将是显示 URL 中的页面层次结构或名称。

更改 URL

我们已经成功实现了更改页面并在浏览器中视觉上标记我们的位置,但浏览器 URL 仍然是被动的。我们将添加一个机制,每次我们更改页面时都会更新路径。

在 JavaScript 中,我们可以使用 History API 来操作访客在浏览器中看到的地址。例如,如果你想显示你处于 /login/,你会实现以下操作:

history.pushState({}, '', '/login/')

我们将要做的就是在事件监听器消息中添加我们刚刚提到的行,以及一个新参数,该参数将始终发送一个名为 urlConsumer 类:

// Event when a new message is received by WebSockets
myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    const selector = document.querySelector(data.selector);
    selector.innerHTML = data.html;
    // Update URL
    history.pushState({}, '', data.url) // New line
    /**
     * Reassigns the events of the newly rendered HTML
     */
    updateEvents();
});

Consumer 中,我们将修改 send_html 函数以支持 url 参数:

def send_html(self, event):
        """Event: Send html to client"""
        data = {
            "selector": event["selector"],
            "html": event["html"],
            "url": event["url"] if "url" in event else "", # New line
        }
        self.send_json(data)

当在 actions.py 中,我们将修改 send_page 以发送路由,但路由是什么?多亏了 Django 和 urls.py,我们可以使用 reverse,它将返回完整路径:

from django.urls import reverse
def send_page(self, page):
...
        self.send_html({
        "selector": "#main",
        "html": render_to_string(f "pages/{page}. html", 
            context),
        "url": reverse(page),
    })
...

现在,我们在导航时可以可视化路由。

![图 6.5 – 动态浏览时显示 URL图片

图 6.5 – 动态浏览时显示 URL

尽管如此,我们有一个严重的限制:我们无法添加 HTML 块。如果我们只想向现有列表添加新元素,例如,渲染整个页面是不高效的。因此,我们将包括一个系统,使我们能够决定是否要将 HTML 块替换或添加到任何可用的选择器。

激活部分或组件

虽然我们有一个可以动态包含从模板渲染的 HTML 并将其应用到文档中现有标签的功能,但我们不能决定我们想要替换还是插入 HTML,换句话说,是进行 hydration 还是替换 DOM。

Hydration是网络开发中的一种技术,其中客户端 JavaScript 通过将事件处理程序附加到 HTML 元素,将静态 HTML 网页转换为动态网页。这允许快速首次内容绘制FCP),但在之后有一段时间页面看起来已经完全加载并且可以交互。然而,直到客户端 JavaScript 执行并且事件处理程序被附加,这才会发生。

为了解决这个问题,我们首先记住Consumer类已经准备好接收append指令:

    def send_html(self, event):
        """Event: Send html to client"""
        data = {
            "selector": event["selector"],
            "html": event["html"],
            "append": "append" in event and 
                event["append"],
            "url": event["url"] if "url" in event else "",
        }
        self.send_json(data)

默认情况下,append将是一个False变量。但如果客户端发送给我们append数据并且它是True,我们将发送我们想要添加到前端的内容,而 JavaScript 将处理其余部分。

我们在static/js/index.js中包含了以下内容,一个条件来控制append

myWebSocket.addEventListener("message", (event) => {
    // Parse the data received
    const data = JSON.parse(event.data);
    // Renders the HTML received from the Consumer
    const selector = document.querySelector(data.selector);
    // If append is received, it will be appended. 
     Otherwise the entire DOM will be replaced.
    if (data.append) {
        selector.innerHTML += data.html;
    } else {
        selector.innerHTML = data.html;
    }
    // Update URL
    history.pushState({}, '', data.url)
    /**
     * Reassigns the events of the newly rendered HTML
     */
    updateEvents();
});

为了检查它是否工作,我们将添加一个laps列表到主页。laps 是一个时间单位,它作为计时器中记录的时间段的记录存储在计时器中。例如,如果是一个一级方程式赛车比赛,你只需通过查看记录的 laps 时间,就可以可视化每辆车完成一圈所需的时间。

每次按按钮时,都会添加一个包含当前时间的新项:

  1. 我们编辑了托管在app/app_template/templates/pages/home.html中的Home模板。我们包括一个按钮和无序列表:

    <section>
        <h2>Laps</h2>
        <p>
            <button id="add-lap">Add lap</button>
        </p>
        <ul id="laps"></ul>
    </section>
    
  2. static/js/index.js示例中,我们用 JavaScript 将事件整合到按钮中。它将只发送一个动作而不带任何数据:

    /**
    * Send new Lap
    * @param {Event} event
    * @return {void}
    */
    function addLap(event) {
        sendData({
            action: 'Add lap',
            data: {}
        }, myWebSocket);
    }
    /**
    * Update events in every page
    * return {void}
    */
    function updateEvents() {
        // Nav
        setEventsNavigation(myWebSocket);
        // Add lap
        const addLapButton = document.querySelector('#add-
            lap');
        if (addLapButton !== null) {
            addLapButton.removeEventListener('click', 
                addLap, false);
            addLapButton.addEventListener('click', addLap, false);
        }
    }
    
  3. Consumer类中,在app/app_template/consumers.py路径下,我们捕获动作并调用一个未来的add_lap函数:

        def receive_json(self, data_received):
            """
                Event when data is received
                All information will arrive in 2 variables:
                "action", with the action to be taken
                "data" with the information
            """
            # Get the data
            data = data_received["data"]
            # Depending on the action we will do one task or another.
            match data_received["action"]:
                case "Change page":
                    actions.send_page(self, data["page"])
                case "Add lap":
                    actions.add_lap(self)
    
  4. actions中,位于app/app_template/actions.py,我们包含了之前提到的add_lap函数。我们在#laps选择器中包含了由名为time的变量渲染的 HTML 片段,该变量包含当前时间:

    def add_lap(self):
        """Add lap to Home page"""
        # Send current time to client
        self.send_html({
            "selector": "#laps",
            "html": render_to_string
                ("components/_lap.html", 
                    {"time": datetime.now()}),
            "append: True,
        })
    
  5. 最后,我们构建了app/app_template/templates/components/_lap.html组件:

    <li>{{ time|date: "h:i:s" }}</li>
    

就这样。我们通过按主页中的添加 laps按钮来测试我们如何随着时间的推移更新列表。

图 6.6 – 向无序列表中添加 HTML 片段以保留按钮点击时记录的内容

图 6.6 – 向无序列表中添加 HTML 片段以保留按钮点击时记录的内容

我们已经改进了 HTML 渲染系统,使其更加选择性和高效。我们现在可以决定何时添加或替换一个 DOM。

如果你更改页面并返回主页,你会发现所有的时间都被删除了。为了避免这个问题,我们可以在数据库中保存时间,或者我们可以通过为用户创建一个临时会话来找到一个最优解。

为客户端创建临时会话

为了为每位客户创建独特的会话,我们需要激活启用此功能的中间件。Channels 为我们提供了SessionMiddlewareStackAuthMiddlewareStack,它们还包括构建登录或注销功能所需的工具。只要可能,我们就会使用AuthMiddlewareStack

我们按照以下方式编辑project_template/asgi.py

import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project_template.settings")
from django.conf import settings
django.setup()
from django.core.asgi import get_asgi_application
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import re_path
from app.app_template.consumers import ExampleConsumer
application = ProtocolTypeRouter(
    {
        # Django's ASGI application to handle traditional HTTP requests
        "http": get_asgi_application(),
        # WebSocket handler
        "websocket": AuthMiddlewareStack(
            URLRouter(
                [
                    re_path(r"^ws/example/$", ExampleConsumer.as_asgi()),   
    }

我们现在可以在Consumer类中创建会话,如下所示:

self.scope["session"]["my name"] = "value".
self.scope["session"]. save()

获取它将与从任何 Python 字典中读取相同:

print(self.scope["session"]["my name"])
# value

为了展示其潜力,我们将在主页上创建一个经典的待办事项应用。即使我们更改页面,我们留下的所有待办任务在我们回来时总是存在,就像现实生活中一样。请看以下:

  1. home模板的末尾,我们包括一个<input>来添加任务的文本,一个触发动作的按钮,以及将要显示的列表:

    <section>
        <h2>TODO</h2>
        <input type="text" id="task">
        <button id="add-task"> Add task</button>
        <ul id="todo">{% include "components/_tasks.html" with tasks=tasks %}</ul>
    </section>
    
  2. 我们需要一个组件来列出所有任务。因此,在app/app_template/templates/components/_tasks.html中,我们包含以下代码:

    {% for task in tasks %}
        {% include "components/_task-item.html" with task=task %}
    {% endfor %}
    
  3. 在前面的组件中,我们使用另一个组件来渲染项目。我们声明app/app_template/templates/components/_task-item.html,其中包含一个<li>和任务的名称:

    <li>{{ task }}</li>
    
  4. Consumer类中,当用户连接时,我们创建一个名为tasks的会话,其中包含一个空列表,我们可以填充它。另一方面,我们捕获从前端接收到的名为"Add task"的动作,并调用actions.py中的add_task函数:

    import app.app_template.actions as actions
    class ExampleConsumer(JsonWebsocketConsumer):
    
        def connect(self):
            """Event when client connects"""
            # Accept the connection
            self.accept()
            # Make session task list
            if "tasks" not in self.scope["session"]:
                self.scope["session"]["tasks"] = []
                self.scope["session"].save()
    def receive_json(self, data_received):
            # Get the data
            data = data_received["data"]
            # Depending on the action we will do one task or another.
            match data_received["action"]:
     # Other actions
                case "Add task":
                    actions.add_task(self, data)
    
  5. actions.py中,我们声明了add_task函数,该函数将任务添加到会话中,但我们还将创建带有session变量的homecontext

    from .forms import LoginForm, SignupForm
    from asgiref.sync import async_to_sync
    from django.template.loader import render_to_string
    from django.urls import reverse
    from channels.auth import login, logout
    from django.contrib.auth.models import User
    from django.contrib.auth import authenticate
    from datetime import datetime
    
    def send_page(self, page):
        """Render HTML and send page to client"""
    
        # Prepare context data for page
        context = {}
        match page:
            case "home":
                context = {"tasks": self.scope["session"]
                    ["tasks"] if "tasks" in self.scope
                        ["session"] else []}
            case "login":
                context = {"form": LoginForm()}
            case "signup":
                context = {"form": SignupForm()}
    ...
    
    def add_lap(self):
        "Add lap to Home page"""""
        # Send current time to client
        self.send_html({
            "selector": "#laps",
            "html": render_to_string
                ("components/_lap.html", {"time": 
                    datetime.now()}),
            "append: True,
        })
    
    def add_task(self, data):
        "Add task from TODO section"""""
        # Update task list
        self.send_html({
            "selector": "#all",
            "html": render_to_string("components/_task-
                item.html", {"task": data["task"]}),
            "append: True,
        })
        # Add task to list
        self.scope["session"]["tasks"].append(data["task"])
        self.scope["session"].save()
    
  6. 最后,在 JavaScript 中,我们给按钮添加一个click事件,以便将文本和任务发送到Consumer类:

    /**
    * Send new task to TODO list
    * @param event
    * @return {void}
    */
    function addTask(event) {
        const task = document.querySelector('#task');
        sendData({
            action: 'Add task',
            data: {
                task: task.value
            }
        }, myWebSocket);
        // Clear input
        task.value = '';
    }
    /**
    * Update events in every page
    * return {void}
    */
    function updateEvents() {
        // Nav
        setEventsNavigation(myWebSocket);
    ...
        // Add task
        const addTaskButton = document.querySelector
            ('#add-task');
        if (addTaskButton !== null) {
            addTaskButton.removeEventListener('click', 
                addTask, false);
            addTaskButton.addEventListener('click', 
                addTask, false);
        }
    }
    

如果出现新的 DOM 元素,我们必须更新。否则,如果之前的 HTML 已被删除,事件将停止工作。要遵循的步骤是停止监听之前存在的事件,并添加新的。如果我们不这样做,事件可能会丢失或重复。

要执行的事件很简单。我们捕获#task字段,并将任务的文本发送给Consumer类。

![图 6.7 – 从会话中显示任务列表图片

图 6.7 – 从会话中显示任务列表

我们已经能够处理会话并从其内容创建 HTML。现在,我们只需要实施一些安全措施来防止 CSRF 攻击。

使用 WebSockets 避免跨站请求伪造(CSRF)

通过使用会话,我们正在将用户暴露于 CSRF 攻击之中,除非我们采取适当的措施。

CSRF 攻击

CSRF 攻击是对网站的一种恶意攻击,其中未经授权的命令从一个用户发送到第二个网站,通过隐藏表单、AJAX 请求或任何其他隐藏方式。

你可以在这里找到参考:en.wikipedia.org/wiki/Cross-site_request_forgery

Channels 提供了一个工具,可以帮助我们以简单的方式避免此类攻击:

  1. 我们在 project_template/settings.py 中定义允许的 Hosts。在我们的案例中,我们正在使用 Docker 中的环境变量:

    ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS"). split(",")
    
  2. 我们编辑 project_template/asgi.py 文件,通过导入 OriginValidator。我们必须传递两个参数:URLRouter(或任何中间件)以及我们想要保护的 Hosts

    # project_template/asgi.py
    import django
    
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project_template.settings")
    from django.conf import settings
    django.setup()
    from django.core.asgi import get_asgi_application
    from channels.security.websocket import OriginValidator # New line
    from channels.auth import AuthMiddlewareStack
    from channels.routing import ProtocolTypeRouter, URLRouter
    from django.urls import re_path
    from app.app_template.consumers import ExampleConsumer
    
    application = ProtocolTypeRouter(
        {
            # Django's ASGI application to handle traditional HTTP requests
            "http": get_asgi_application(),
            # WebSocket handler
            # Update
            "websocket": OriginValidator
                (AuthMiddlewareStack(
                URLRouter(
                    
                        re_path(r"^ws/example/$", 
                            ExampleConsumer.as_asgi()),
    
            ), settings.ALLOWED_HOSTS)
        }
    

由于此功能实现起来非常快,强烈建议它始终是我们未来项目的一部分或集成到我们用作基础的模板中。

摘要

在本章中,我们为我们项目添加了一些非常有趣的新功能:在页面之间切换、创建每个路径的服务器端渲染版本、创建动态页面、修改 URL、更新特定部分、使用会话以及通过 WebSockets 避免 CSRF 攻击。

现在我们已经拥有了构建具有数据库访问、分组管理、部分或完整 HTML 渲染、触发后端操作的事件控制、表单创建和一些安全措施的基本技能。可能有一个问题在你的脑海中回响:这一切的努力是否值得?只需想想我们现在可以以最小的 JavaScript 使用量创建单页应用(SPAs),我们不需要构建一个 API 来连接前端和后端,请求和响应之间的时间非常低,在很多情况下避免了加载。项目的复杂性也降低了,我们可以避免安装多个前端库。由你自己来判断。最令人惊讶的是,我们只使用了 Django 和 Channels;通过添加其他 Python 扩展,我们可以实现无限的可能性。

在下一章,[第七章仅使用 Django 创建实时博客,我们将把所有这些部分组合起来,以展示一个我们可以用于我们自己的项目或外部项目的实际案例。

第七章:仅使用 Django 创建实时博客

第六章,“在后端创建 SPAs”中,我们学习了使用 HTML 通过 WebSockets 设置 SPA 的基本特性,例如更改页面、组件和会话。我们甚至更进一步,为每个页面创建了一个服务器端渲染系统,以便搜索引擎可以索引所有内容——这个特性在我们使用 Django 时不需要太多努力。

现在我们有了制作包含 SPA 开发所需所有特性的应用程序的技能和成熟度。现在是时候了!我们将统一在创建一个完美准备的博客中获得的全部知识。毫无疑问,无论我们想要吸收的语言或框架如何,这都是一个极好的练习;它涵盖了任何网络开发的基本任务:查询、过滤和添加到数据库(搜索引擎和评论)、从结果生成 HTML(文章列表和单个页面)、使用视图(SSR)、路由(静态页面)、处理和验证表单(添加新评论),最后,分页。

这是一个证明给世界和你自己看,你对该学科有基本知识的考试。甚至可以是一个好的技术测试。

在创建博客的过程中,我们将执行以下操作:

  • 为数据库创建模型

  • 生成假文章和评论

  • 文章列表

  • 使用分页在文章之间导航

  • 添加文章搜索引擎

  • 创建静态页面

  • 在页面之间移动并生成浏览器

  • 为每篇文章实现一个单独的页面

  • 添加评论列表

  • 添加新评论

  • 提供一个非常简单的聚合RSS)源

在本章中,我们将以小里程碑的形式进行工作,遵循一个允许我们有机地融入每个元素的顺序,避免从一个特性跳到另一个特性直到完成。你可以找到我们将要单独实现的每个特性的代码(在先前的列表中)。

为了不使示例过于复杂,我们将从一个我们在前几章中使用的代码库开始。

技术要求

所有不同部分的代码都可以在以下链接中找到:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-7

就像其他示例一样,我将从我们在第四章,“使用数据库工作”中构建的模板开始:

github.com/PacktPublishing/Building-SPAs-with-Django-and-HTML-Over-the-Wire/tree/main/chapter-4/initial-template

如果你发现一些小的差异,那是因为我进行了一些小的调整。例如,我已将项目命名为blog,应用命名为website,并将路径更改为http://blog.localhost,尽管如此,你仍然可以自由地为每个元素命名。

为数据库创建模型

我们将在数据库中建立两个表:Post,它将包含文章,以及Comment,以便读者可以在文章旁边留下他们的意见。

app/website/models.py中添加以下数据库结构:

from django.db import models
from django.utils.text import slugify
from django.urls import reverse
class Post(models.Model):
    # Fields: Title of the article, name of the author, 
    content of the article and date of creation.
    title = models.CharField(max_length=200, unique=True)
    author = models.CharField(max_length=20)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    class Meta:
        ordering = ["-created_at"]
    @property
    def slug(self):
        return slugify(self.title)
    @property
    def summary(self):
        return self.content[:100] + "..."
    @property 
    def get_absolute_url(self):
        return reverse("single post", kwargs={"slug": 
            self.slug})
    def __str__(self):
        return self.title
class Comment(models.Model):
    # Fields: Name of the author, content of the comment, 
    relation to the article and date of creation.
    author = models.CharField(max_length=20)
    content = models.TextField()
    post = models.ForeignKey(Post, on_delete=models.
        CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    def __str__(self):
        return self.name

让我们看看Post的属性:

  • slug:我们将使用文章的标题来区分路由。例如,如果标题为Penguins have just conquered the world,其最终路径将是http://blog.localhost/penguins-have-just-conquered-the-world。利用这个属性,我们准备好标题用于不同的目的,例如填充其他属性或搜索文章的 ID。

Slug

Slug 是一种用于 URL 的格式,用于使 URL 更易读,其中空格被单个破折号替换,文本被转换为小写。在 SEO 等区域,它用于解释页面内容。

  • summary:当我们列出文章时,我们将显示原始文章的一小部分。利用这个属性,我们限制显示文章的部分为 100 个字符,并在句尾添加一些漂亮的点。它并不完美,因为它计算空格,并且不检查初始长度,但肯定足以满足目的。

  • get_absolute_url:通过在urls.py中定义的路径,我们将为每篇文章构建超链接。为什么?我们将动态移动。例如,它们用于 RSS 源,或未来的网站地图。

下一步,就像我们在每个活动中所做的那样,是进入 Django 容器终端并执行以下操作:

python3 manage.py makemigrations
python3 manage.py migrate

数据库已就绪。然而,没有数据,它并不实用。像其他时候一样,我们将创建模拟博客最终外观的假内容。

生成假文章和评论

在从模型定义数据库之后,我们将生成随机数据,以便我们能够更舒适地工作。

我们创建make_fake_data.py,内容如下:

from app.website.models import Post, Comment
from faker import Faker
# Delete all posts and comments
Post.objects.all().delete()
# Create fake object
fake = Faker()
def get_full_name():
    return f"{fake.first_name()} {fake.last_name()}"
# Create 30 posts
for _ in range(30):
    post = Post(
        title=fake.sentence()[:200],
        content=fake.text(),
        author=get_full_name()[:20],
    )
    post.save()
# Create 150 comments
for _ in range(150):
    comment = Comment(
        author=get_full_name()[:20],
        content=fake.text(),
        post=Post.objects.order_by("?").first(),
    )
    comment.save()

我们将要运行的代码将生成随机信息。我们遵循的步骤如下:

  1. 我们删除所有文章,或Post。第一次运行时,将没有东西可以删除,但之后,它将删除它找到的每一项。

  2. 我们生成 30 篇新文章。

  3. 我们生成 150 条评论,或Comment,并将它们随机分配给文章。这样,它们将不规则地分布,有些文章没有评论,而有些文章有大量评论。

最后,在 Django 容器终端中,我们执行我们刚刚构建的脚本:

python3 manage.py shell < make_fake_data.py

我们的数据库已填充信息。现在,我们将专注于博客的逻辑——例如,以 HTML 格式列出所有文章。

文章列表

我们通过模型和包含必要的元素来准备数据库,并添加虚假信息,使我们能够专注于客户如何可视化内容。

在构建不同的页面之前,我们需要一个所有模板的基础。在app/website/templates/base.html中,我们包含了主布局:

{% load static %}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,
        user-scalable=no, initial-scale=1.0, maximum-
            scale=1.0, minimum-scale=1.0">
    <title>Example website</title>
    <link rel="stylesheet" href="{% static 'css/main.css' 
        %}">
    <script defer src="img/index.js' %}">
    </script>
</head>
<body
        data-host="{{ request.get_host }}"
        data-scheme="{{ request.scheme }}"
>
    <div class="container">
        <header>
            <nav id="nav" class="nav">{% include 
                'components/_nav.html' %}</nav>
        </header>
        <main id="main">{% include page %}</main>
        <footer class="footer">My footer</footer>
    </div>
</body>
</html>

我们已经包含了用于重绘元素的区域,例如浏览器中的#nav和未来页面的主要内容#main

现在,我们将创建博客的欢迎页面,我们将列出文章。

第一步将是创建一个 HTML 模板来生成不同博客文章的列表,它将由未来的数据库查询提供数据。在app/website/templates/pages/all_posts.html中,我们添加以下代码:

<h1> All posts</h1>
<hr>
<section>
    {# List posts #}
    <div id="all-posts">
        {% include "components/all_posts/list.html" %}
    </div>
    {# End list posts #}
</section>

我们将文章列表分离到一个托管在app/website/templates/components/all_posts/list.html的组件中,因为当进行分页时这将很有用。

使用以下代码,我们将通过include展示#all-posts内部将显示的所有文章列表:

{% for post in posts %}
    <article>
        <header>
            <h2>{{ post.title }}</h2>
        </header>
        <p>{{ post.summary }}</p>
        <p>{{ post.author }}</p>
        <footer>
            <p>
                <a class="post-item__link" href="#" data-
                    target="single post" data-id="{{ 
                        post.id }}"> Read more</a>
            </p>
        </footer>
    </article>
{% endfor %}

目前,指向文章单独页面的超链接无法工作。当我们有了正确的模板,我们将回来用 JavaScript 给它添加逻辑。然而,我们已经为动态页面更改准备了数据集:要加载的页面名称(data-target)及其 ID(data-id)。

app/website/views.py中,我们创建了以下视图:

from django.shortcuts import render
from .forms import SearchForm, CommentForm
from .models import Post, Comment
def all_posts(request):
    return render(
        request,
        "base.html",
        {
            "posts": Post.objects.all()[:5],
            "page": "pages/all_posts.html",
            "active_nav": "all posts",
        },
    )

我们将只列出前五项;这是我们每页将显示的项目数量。

blog/urls.py中,我们分配网站的根路径:

from django.contrib import admin
from django.urls import path
from app.website import views, feed
urlpatterns = [
    path("", views.all_posts, name="all posts"),
    path("admin/", admin.site.urls),
]

当你通过docker-compose.yaml启动 Docker,并访问http://blog.localhost时,你将找到文章:

![图 7.1 – 显示博客根目录下的前五篇文章

![img/Figure_7.01_B18321.jpg]

图 7.1 – 显示博客根目录下的前五篇文章

如果我想看到更多文章怎么办?我们目前还不能,尽管我们将在下一节中解决这个问题。下一个挑战将是通过分页或连续渲染下一篇文章来解决此问题。

使用分页在文章之间导航

我们能够向访客展示最新的文章,但他们无法查看之前的帖子。我们将包含一个按钮,允许我们在欢迎页面上渲染其他文章,并将它们以五个一组的形式包含在内。

我们添加了一个带有按钮的组件。在app/website/templates/components/all_posts/_button_paginator.html中添加以下 HTML:

{% if not is_last_page %}
<button class="button" id="paginator" data-next-page="{{ 
    next_page }}">More posts</button>
{% endif %}

我们只有在不是最后一页时才会显示按钮,我们将使用is_last_page变量来管理这一点。此外,我们将包含一个带有next_page变量的数据集,以告诉后端我们想要渲染的下一页。

组件嵌入在app/website/templates/components/all_posts/list.html中:

<h1>All posts</h1>
<hr>
<section>
    {# List posts #}
    ...
    {# End list posts #}
    {# Paginator #}
    <div id="paginator">
        {% include 
           "components/all_posts/_button_paginator.html" %}
    </div>
    {# End paginator #}
</section>

在设计视觉部分之后,我们将关注常规流程以提供逻辑。

我们转到 static/js/index.js 来捕获点击事件,并将 "Add next posts" 操作与我们要渲染的页码一起发送给 Consumer。

我省略了模板中已经存在的行,以简化示例:

/**
* Event to add a next page with the pagination
* @param event
*/
function addNextPaginator(event) {
    const nextPage = event.target.dataset.nextPage;
    sendData({
        action: "Add next posts",
        data: {
            page: nextPage
        },
    }, myWebSocket);
} 
/**
* Update events in every page
* return {void}
*/
function updateEvents() {
...
    // Paginator
    const paginator = document.querySelector("#paginator");
    if (paginator !== null) {
        paginator.removeEventListener("click", 
            addNextPaginator, false);
        paginator.addEventListener("click", 
            addNextPaginator, false);
    }
}

我们向托管在 app/website/consumers.py 中的 Consumer 添加了如果收到 "Add next posts" 的适当调用操作。

正如我们在几次场合所做的那样,我们将在 Consumer 类中创建一个链接,将前端所需操作与 actions.py 中托管的功能连接起来:

from channels.generic.websocket import 
    JsonWebsocketConsumer
from asgiref.sync import async_to_sync
import app.website.actions as actions
class BlogConsumer(JsonWebsocketConsumer):
    room_name = "broadcast"
    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept()
        # Assign the Broadcast group
        async_to_sync(self.channel_layer.group_add)
            (self.room_name, self.channel_name)
    def disconnect(self, close_code):
        """Event when client disconnects"""
        pass
    def receive_json(self, data_received):
        ...
        # Get the data
        data = data_received["data"]
        # Depending on the action we will do one task or 
         another.
        match data_received["action"]:
            case "Change page":
                actions.send_page(self, data)
            case "Add next posts":
                actions.add_next_posts(self, data)
    def send_html(self, event):
        ...

app/website/actions.py 中,我们声明 add_next_posts 函数:

POST_PER_PAGE = 5
def add_next_posts(self, data={}):
    """Add next posts from pagination"""
    # Prepare context data for page
    page = int(data["page"]) if "page" in data else 1
    start_of_slice = (page - 1) * POST_PER_PAGE
    end_of_slice = start_of_slice + POST_PER_PAGE
    context = {
        "posts": Post.objects.all()[start_of_slice:end_of_slice],
        "next_page": page + 1,
        "is_last_page": (Post.objects.count() // 
            POST_PER_PAGE) == page,
    }
    # Add and render HTML with new posts
    self.send_html(
        {
            "selector": "#all-posts",
            "html": render_to_string
               ("components/all_posts/list.html", context),
            "append: True,
        }
    # Update paginator
    self.send_html(
        {
            "selector": "#paginator",
            "html": render_to_string(
                "components/all_posts/_button_paginator.
                     html", context
            ),
        }

我们正在进行一系列重要的操作:

  • 我们正在保存要显示的页面。如果没有提供,我们假设它是第一个。

  • 我们正在计算结果的初始和最终截止点。

  • 我们正在执行查询。

  • 我们正在计算下一页将是什么——当前页加一。

  • 我们正在检查我们是否在最后一页。知道是否应该打印分页按钮将非常重要。

  • 我们正在渲染新的文章并将它们添加到 #all-posts

  • 我们正在重新绘制分页按钮,因为它需要存储下一页的内容,如果没有更多文章,则隐藏它。

只剩下一个小细节。将初始参数传递给视图 (app/website/views.py):

def all_posts(request):
    return render(
        request,
        "base.html",
        {
            "posts": Post.objects.all()[:5],
            "page": "pages/all_posts.html",
            "active_nav": "all posts",
            "next_page": 2, # New
            "is_last_page": (Post.objects.count() //
               POST_PER_PAGE) == 2, # New
        },

我们现在可以开始渲染新的结果:

图 7.2 – 文章分页

图 7.2 – 文章分页

如果有一个漂亮的动画或延迟,体验会更好;它加载得如此快,以至于访客可能不会注意到新元素。我们可以将这个问题留给未来的网页设计师。我们的任务还没有完成——如果访客正在寻找特定的文章呢?分页变得繁琐;如果有一个简单的搜索引擎,一切都会更容易。

添加文章搜索引擎

为访客提供分页是一种优化资源并提供受控导航的好方法。此外,包括文章搜索引擎将提供完整的探索。这就是为什么我们将集成一个文本字段来通过标题查找文章。

app/website/forms.py 中,我们整合了以下表单,它将只有一个字段:

from django import forms
from . models import Comment
class SearchForm(forms.Form):
    search = forms.CharField(
        label="Search",
        max_length=255,
        required=False,
        widget=forms.TextInput(
            attrs={
                "id": "search",
                "class": "input",
                "placeholder": "Title...",
            }
        ),

我们需要一个组件来渲染我们刚刚定义的表单。我们创建 app/website/templates/components/all_posts/form_search.html 文件,并在表单内添加 search 字段:

<form id="search-form" action="">
    {{ form.search }}
    <input class="button" type="submit" value="Search">
</form>

在文章列表页面,app/website/templates/pages/all_posts.html,我们包含 search 组件:

<h1> All posts</h1>
<hr>
{# Search #}
<section id="form-search">
    {% include "components/all_posts/form_search.html" %}
</section>
{# End search #}
<hr>
<section>
    {# List posts #}
    ...
    {# End list posts #}
    {# Paginator #}
    ...
    {# End paginator #}
</section>

不要忘记将其包含在视图 (app/website/views.py) 中:

def all_posts(request):
    return render(
        request,
        "base.html",
        {
            "posts": Post.objects.all()[:5],
            "page": "pages/all_posts.html",
            "active_nav": "all posts",
            "form": SearchForm(), # New
            "next_page": 2,
            "is_last_page": (Post.objects.count() // 
                POST_PER_PAGE) == 2,
        },

当页面加载时,我们将看到其形式,尽管目前它只是装饰性的,因为它背后没有逻辑:

图 7.3 – 显示浏览器

图 7.3 – 显示浏览器

现在,让我们转到 static/js/index.js 来使其工作。我们将捕获表单的提交事件,并将 "Search" 操作与要搜索的文本一起发送给 Consumer:

/**
* Event to request a search
* @param event
*/
function search(event) {
    event.preventDefault();
    const search = event.target.querySelector("#search"). value;
    sendData({
        action: "Search",
        data: {
            search: search
        },
    }, myWebSocket);
}
/**
* Update events in every page
* return {void}
*/
function updateEvents() {
...
    // Search form
    const searchForm = document.querySelector("#search-
        form");
    if (searchForm !== null) {
        searchForm.removeEventListener("submit", search,         
            false);
        searchForm.addEventListener("submit", search, 
            false);
    }
...
}

前端已经发送了我们需要请求和信息。现在,消费者(app/website/consumers.py)应该执行适当的操作:

match data_received["action"]:
...
             case "Search":
                actions.search(self, data)
...

然后,在操作(app/website/actions.py)中,我们包括search函数:

def search(self, data={}):
    "Search for posts"    ""
    # Prepare context data for page
    context = {
        "posts": Post.objects.filter
            (title__icontains=data["search"])
                [:POST_PER_PAGE].
    }
    # Render HTML page and send to client
    self.send_html(
        {
            "selector": "#all-posts",
            "html": render_to_string
               ("components/all_posts/list.html", context),
        }

如您所见,代码很简单。我们只是通过获取包含data["search"]的所有文章来过滤数据库,忽略大小写文本(icontains)。我们还限制结果为五篇文章。

就这样。我们可以搜索并动态显示结果:

图 7.4 – 显示对单词“为什么”的搜索结果

图 7.4 – 显示对单词“为什么”的搜索结果

如果您通过留空字符串进行搜索,您将返回到上一个状态,其中项目未经过滤地列出。

接下来要讨论的是页面之间的导航。为此,我们将创建一个静态页面,其中我们可以描述博客或显示关于我们页面,以及一个导航器来在现有页面之间移动。

创建静态页面

我们处于需要通过添加新页面来扩展逻辑和 HTML 结构的情况。第一步将是创建一个静态页面。

我们创建app/website/templates/pages/about_us.html,其中包含简单的文本:

<h1> About us</h1>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad animi aut beatae commodi consectetur cumque ipsam iste labore laudantium magni molestiae nobis nulla quod quos tempore totam velit, voluptas voluptates!</p>

我们编辑视图(app/website/views.py),包括about

def about(request):
    return render(
        request,
        "base.html",
        { "page": "pages/about_us.html", "active_nav": 
           "about us"},

然后,我们在blog/urls.py中为其提供一个路径:

urlpatterns = 
...
        path("about-us/", views.about, name="about us"),
...

现在,我们可以访问http://blog.localhost/about-us/来查看页面:

![图 7.5 – 渲染“关于我们”页面

图 7.5 – 渲染“关于我们”页面

我完全同意你的观点;这一部分并没有做得很好...我承认错误!在 Django 中,创建一个静态页面是我们能做的最基本的事情。现在,是时候处理困难的部分了:在页面之间动态滚动并创建一个浏览器。

在页面之间移动和生成浏览器

访问者需要在不同的页面之间导航;需要整合一个简单的按钮结构和相应的逻辑来加载适当的模板。

我们将创建一个浏览器,以动态地在页面之间跳转,换句话说,请求后端在正确的位置渲染页面:

  1. 第一步是创建一个带有超链接的组件。我们在app/website/templates/components/_nav.html中创建一个文件,具有以下结构:

    <ul class="nav__ul">
        <li>
            <a
                    href="#"
                    class="nav__link nav__link nav__link--page{% if active_nav == "all posts" %} active{% endif %}""
                    data-target="all posts"
            >
                All posts
            </a>
        </li>
        <li>
            <a
                    href="#"
                    class="nav__link nav__link nav__link
                        page{% if active_nav == "about us" 
                            %} active{% endif %}"
                    data-target="about us"
            >
                About us
            </a>
        </li>
    </ul>
    

The active_nav变量值得提一下。我们为这个特定的组件在每个视图中定义了它。它将添加一个 CSS 类,从视觉上标记访问者的位置。我们还包含了target数据集,以了解每个超链接应该指向哪里。

接下来,我们将捕获 JavaScript 中超链接的事件,其目的是更改页面,包括浏览器中现有的和文章列表中的:

  1. 我们在static/js/index.js中添加以下内容:

    /**
    * Send message to update page
    * @param {Event} event
    * @returns {void}
    */
    function changePage(event) {
        event.preventDefault();
        sendData({
            action: "Change page",
            data: {
                page: event.target.dataset.target,
                id: event.target. dataset?.id
            }
        }, myWebSocket);
    }
    /**
    * Update events in every page
    * return {void}
    */
    function updateEvents() {
    ...
        // Navigation
        document.querySelectorAll(".nav__link--page").forEach(link => {
            link.removeEventListener("click", changePage, false);
            link.addEventListener("click", changePage, false);
        });
        // Link to single post
        const linksPostItem = document.querySelectorAll
            (".post-item__link");
        if (linksPostItem !== null) {
            linksPostItem.forEach(link => {
                link.removeEventListener("click", 
                    changePage, false);
                link.addEventListener("click", changePage,       
                    false);
            });
        }
    …
     }
    

当点击超链接时,将向消费者发送请求,通过发送模板名称来更改页面,如果存在,则发送帖子的 ID。

  1. 我们在消费者模块 (app/website/consumers.py) 中包含了对 "Change page"send_page 调用:

    ...
    ca"e "Change page":
    actions.send_page(self, data)
    ...
    
  2. 在实际操作中,我们通过添加模板上下文模板来编辑 send_page 函数,就像我们在以前的项目中所做的那样:

    POST_PER_PAGE = 5
    
    def send_page(self, data={}):
    ...
         match page:
            case "all posts":
                context = {
                    "posts": Post.objects.all()
                        [:POST_PER_PAGE],
                    "form": SearchForm(),
                    "next_page": 2,
                    "is_last_page": (Post.objects.count() 
                    // POST_PER_PAGE) == 2,
                }
     ...
    

这实际上与负责显示所有项目的视图相同。

我们现在可以通过 CSS 样式来在页面间移动并可视化我们的位置:

![Figure 7.6 – 浏览器显示我们处于“所有文章”页面Figure 7.06_B18321.jpg

图 7.6 – 浏览器显示我们处于“所有文章”页面

我们管理所有情况。访客可以从任何类型的页面导航,从动态页面到内容静态的另一个页面。

![Figure 7.7 – 浏览器显示我们处于“关于我们”页面Figure 7.07_B18321.jpg

图 7.7 – 浏览器显示我们处于“关于我们”页面

然而,显示文章全文和评论的动态页面仍然缺失。通过有一个导航系统,将其集成将相对容易。

实现每个文章的独立页面

我们有机会创建一个渲染整篇文章的页面,这将作为整个评论系统的基石。

我们在 app/website/templates/pages/single_post.html 中创建模板,其中包含基本的但足以显示最小 Post 字段的 HTML:

<section>
    {# Post #}
    <article>
        <header>
            <h1>{{ post.title }}</h1>
        </header>
        <div>{{ post.content }}</div>
        <footer>
            <p>{{ post.author }}</p>
        </footer>
    </article>
    {# End post #}
</section>

现在,我们将专注于服务器端渲染,通过创建视图及其路径。

app/website/views.py 中,我们包含了以下函数:

from .models import Post
def single_post(request, slug):
    post = list(filter(lambda post: post.slug == slug, Post.objects.all()))[0]
    return render(
        request,
        "base.html",
        {
            "post: post,
            "page": "pages/single_post.html",
        },

为什么使用 filter 函数?因为我们已经决定 URL 将由一个 slug 组成,当我们收到渲染视图的请求时,我们需要查找具有 slug 属性的文章。Django 不允许你通过属性进行查询。换句话说,我们不得不进行手动过滤。

我们在 blog/urls.py 中整合了路由:

urlpatterns = [
    ...
    path("article/<slug:slug>/", views.single_post, 
        name="single post"),
    ...

现在,我们需要在前端请求切换页面时,整合一个上下文,或者一组用于渲染新 HTML 的变量。在 app/website/actions.py 中,我们添加了以下内容:

data_reverse = {}
match page:
...
        case "single post":
            post = Post.objects.get(id=data["id"])
            context = {
                "post: post,
            }
            data_reverse = {"slug": post.slug}
...
self.send_html(
        {
            "selector": "#main",
            "html": render_to_string(f 
                "pages/{template_page}.html", context),
            "url": reverse(page, kwargs=data_reverse),
        }

在这个时刻,从文章列表中,我们将能够加载单个模板。同时,路径也将随着 slug 的 URL 结构而改变:

![Figure 7.8 – 单篇文章页面已渲染Figure 7.08_B18321.jpg

![Figure 7.8 – 单篇文章页面已渲染然而,页面还没有完成;我们仍然需要在模板底部列出评论并包含一个表单来添加新的评论。# 添加评论列表博客功能齐全:我们可以列出文章、在页面间导航、分页和进行搜索。但还缺少一个基本元素:评论。这就是为什么我们将打印属于一篇文章的所有评论。我们首先创建一个列出所有评论的模板。我们在 app/website/templates/components/_list_of_comments.html 中添加了一个新的组件,内容如下:py{% for comment in comments %}``````py    {% include "components/_single_comment.html" with ``````py       comment=comment %}``````py{% endfor %}这反过来又需要 app/website/templates/components/_single_comment.html 组件:py<article>``````py    <h2>{{ comment.author }}</h2>``````py    <p>{{ comment.content }}</p>``````py    <p>{{ comment.created_at }}</p>``````py</article>在视图 (app/website/views.py) 中,我们查询属于我们正在查看的帖子的所有评论,并将其发送到模板:pyfrom .models import Post, Comment``````pydef single_post(request, slug):``````py    ...``````py        {``````py            "post: post,``````py            "page": "pages/single_post.html",``````py            "active_nav": "single post",``````py            "comments": Comment.objects.filter(post=post), # New``````py        },``````py...我们现在可以显示文章上的评论列表。图 7.9 – 所有评论都渲染在文章页面上

图 7.9 – 所有评论都渲染在文章页面上

然而,为了在动态更改页面时也显示评论,我们必须在操作中的 send_page 内包含评论变量:

def send_page(self, data={}):
        ...
        case "single post":
            post = Post.objects.get(id=data["id"])
            context = {
                "post: post,
                "form": CommentForm(),
                "comments": Comment.objects.filter(post=post), # New
            }
            data_reverse = {"slug": post.slug}
 ...

我们现在将包含一个供访客添加评论的表单。但不仅如此:我们已经生成了随机信息、文章列表、每篇文章的单页、动态切换页面的系统、浏览器、搜索引擎和服务器端渲染系统。目前,我们有一个非常有趣的博客。接下来,我们将看到如何添加新的评论。

添加新评论

如果所有评论都是我们写的,那就有点不道德了。我们将包含一个表单,以便任何阅读文章的人都可以留下个人意见。如果你不喜欢他们说的话,你总是可以用 Django 的管理面板来“管理”它。但现在,让我们不要玩弄技巧;让我们专注于更技术的一面。

首先,我们在 app/website/forms.py 中添加以下表单:

class CommentForm(forms.ModelForm):
    author = forms.CharField(
        widget=forms.TextInput(
            attrs={
                "id": "author",
                "class": "input",
                "placeholder": "Your name...",
            }
        ),
    content = forms.CharField(
        widget=forms.Textarea(
            attrs={
                "id": "content",
                "class": "input",
                "placeholder": "Your comment...",
            }
        ),
    class Meta:
        model = Comment
        fields = ("author", "content", "post")

与搜索引擎表单相比,有一个重要的区别:我们使用 ModelForm。现在我们可以从表单对象本身创建新的评论。

在视图 (app/website/views.py) 中,我们导入并包含表单对象到模板中:

from . forms import SearchForm, CommentForm
def single_post(request, slug):
    ...
        {
            "post: post,
            "page": "pages/single_post.html",
            "active_nav": "single post",
            "comments": Comment.objects.filter(post=post),
            "form": CommentForm(), # New
        },
...

现在,在 app/website/templates/pages/single_post.html 中,我们渲染表单:

   ...
{# Comments #}
    <div id="comments">
        <h2> Comments</h2>
        <form id="comment-form" action="" data-post-id="{{ 
            post.id }}">
            {{ form.author }}
            {{ form.content }}
            <input class="button" type="submit" 
                value="Add">
        </form>
        <div id="list-of-comments">
            {% include "components/_list_of_comments.html" %}
        </div>
    </div>
    {# End comments #}
</section>

点击任何项目,您将能够查看表单:

图 7.10 – 渲染表单以添加新评论

图 7.10 – 渲染表单以添加新评论

现在,我们将处理前端表单。我们捕获提交事件,当它触发时,我们将获取三个字段:作者、内容和文章 ID。我们将发送一个请求来执行 "Add comment" 操作。

我们在 static/js/index.js 中添加了以下函数:

function addComment(event) {
    event.preventDefault();
    const author = event.target.querySelector("#author"). value;
    const content = event.target.querySelector("#content"). value;
    const postId = event.target.dataset.postId;
    sendData({
        action: "Add comment",
        data: {
            author: author,
            content: content,
            post_id: postId
        },
    }, myWebSocket);
}
function updateEvents() {
    ...
    // Comment form
    const commentForm = document.querySelector("#comment-form");
    if (commentForm !== null) {
        commentForm.removeEventListener("submit", addComment, false);
        commentForm.addEventListener("submit", addComment, false);
    }
...
}

在消费者 app/website/consumers.py 中,如果我们收到 "Add comment",我们将在操作中调用 add_comment 函数:

        match data_received["action"]:
...            
            case "Add comment":
                actions.add_comment(self, data)

为了完成流程,在操作 (app/website/actions.py) 中,我们创建了一个调用消费者 – add_comment 的函数:

from . models import Post, Comment
from . forms import SearchForm, CommentForm
def add_comment(self, data):
    """Add new comment to database""""
    # Add post
    data_with_post = data.copy()
    post = Post.objects.get(id=data["post_id"])
    data_with_post["post"] = post
    # Set initial values by CommentForm
    form = CommentForm(data_with_post)
    # Check if form is valid
    if form.is_valid():
        # Save comment
        form.save()
        # Render HTML with new comment to all clients
        async_to_sync(self.channel_layer.group_send)(
            self.room_name,
            {
                "type": "send.html", # Run "send_html()" 
                    method
                "selector": "#comments",
                "html": render_to_string(
                    "components/_single_comment.html", 
                      {"comment": data}, {"comment": data}.
                ),
                "append": True,
                "broadcast: True,
                "url": reverse("single post", 
                    kwargs={"slug": post.slug}),
            },

我们执行一系列操作,这些操作必须按以下顺序进行:

  1. 我们从接收到的 ID 中获取帖子。

  2. 我们在字典中包含了所有信息,并将帖子包含在内。我们需要添加一个对象来执行表单的验证。

  3. 使用字典,我们初始化表单。

  4. 我们验证字段是否正确。如果不正确,则其余代码将被简单地忽略。

  5. 如果它们是正确的,我们将使用 form.save() 在数据库中创建新的评论。表单知道要创建哪个模型,因为它是内部 ModelForm,我们在 forms.py 中告诉它。

  6. 我们将新评论的 HTML 发送到所有连接的客户端。

  7. 不仅评论经过验证并保存,而且还会实时发送给所有文章的读者。然而,您应该意识到,如果字段无效,我们不会提供反馈。简单来说,直到所有字段都填写完毕,信息才不会被处理。

我们可以在这里停止,但如果我们要创建一个博客,还有一个细节我认为是必不可少的:一个 RSS 订阅源,这样我们的未来访客就可以了解到最新的消息。

提供 RSS 订阅源

技术博客通常被机器人消费,特别是被订阅源消费。如果我们想在 Django 中构建一个订阅源,那是非常方便的。Django 集成了一个名为Syndication的框架,它可以自动化诸如动态生成 XML、字段和缓存等任务。

app/website/feed.py中,我们添加以下从Feed继承的类:

from django.contrib.syndication.views import Feed
from django.urls import reverse
from .models import Post
class LatestEntriesFeed(Feed):
    title = "My blog"
    link = "/feed/"
    description = "Updates to posts."
    def items(self):
        return Post.objects.all()[:5]
    def item_title(self, item):
        return item.title
    def item_description(self, item):
        return item.summary
    def item_link(self, item):
        return reverse("single post", kwargs={"slug": 
            item.slug})

最后,我们在blog/urls.py中包含其路径:

...
from app.website import views, feed
urlpatterns = 
    ...
    path("feed/", feed.LatestEntriesFeed(), name="feed"),
    ...

您可以将您喜欢的订阅源客户端的路径http://blog.localhost/feed/提供给它们。如果您直接从浏览器中输入,将会下载一个 XML 文件。

摘要

我们可以将这一章视为本书所学所有技能的总结。我们不仅能够通过 channels 将 WebSocket 服务器集成到 Django 中,现在我们还拥有了使用 Python 创建实时、单页应用的技术。我们现在拥有与使用其他类似项目(如 Elixir 生态系统中最受欢迎的框架 Phoenix 中的 LiveView、StimulusReflex、Turbo、Action Cable 或 Ruby on Rails 中的 Hotwire)所能实现的结果相匹配的深入知识。

如果我们想要抽象部分过程,Django 中存在一些框架可能很有用,例如 Django Sockpuppet 或 Django Reactor。不幸的是,它们都没有收到更新,尽管了解它们的构建方式以进一步扩展我们的知识是一个很好的想法。

虽然后端已经覆盖,但与前端工作仍然很繁琐。每次绘制时都必须重新声明事件,而且我们重复执行的任务很多,每次都要管理每个元素。我们需要简化这个过程。

在下一章([第八章简化前端中,我们将使用一个专门设计用来重建 DOM 而不改变其工作方式的 JavaScript 事件库。

第四部分:使用 Stimulus 简化前端

在这部分,我们将使用最广泛使用的 JavaScript 框架之一来轻松处理从后端接收的事件:Stimulus。它不仅会减少 JavaScript 代码的数量,而且还会允许前端定义事件。

在这部分,我们将涵盖以下章节:

  • 第八章,简化前端

第八章:简化前端

在整个章节(例如,进行聊天项目或博客),我们编写了混乱的 JavaScript 代码。每当后端发送新的 HTML 时,我们被迫重复任务,清理孤儿事件并将新事件重新分配给新创建的 DOM。我们对前端的目标相当谦虚。我们限制自己通过专注于 Django 代码来生存。如果我们有一个通过服务器渲染的 HTML 处理事件的工具,JavaScript 代码将更加简洁,并且更容易处理。是时候重构前端了,但我们需要帮助来完成这项工作。

Stimulus 是这项工作的理想选择。我们谈论的是一个框架,其目标是通过对我们指定的函数连接属性和事件来不断监控页面上的变化。我们可以创建控制器,通过数据集将它们分配给输入或任何其他需要添加事件的元素。然后,我们将每个事件与 JavaScript 中的某些逻辑相关联。这是一个在 Stimulus 的官方文档中找到的精彩定义:你应该将 Stimulus 视为一个 CSS 类,它将一个 HTML 元素与一组样式连接起来。

在本章中,我们将专注于创建一个使用 Stimulus 的最小示例,它将作为理解其工作原理的基础,并且可以在网站上的任何事件中实现。我们将按顺序介绍以下内容:

  • 安装和配置 Stimulus

  • 定义控制器

  • 使用动作管理事件

  • 使用目标捕获引用

  • 构建一个将文本转换为 uppercase 字母的应用程序

最终目标是构建一个小的应用程序,我们在文本框中编写,实时可视化相同的字符串,但以大写字母形式。为此,我们将使用 Stimulus 来捕获事件和输入值并与消费者进行通信。当一切就绪时,你将惊讶于前端将多么优雅。

技术要求

示例基于我们在 第四章 “与数据库一起工作” 中使用的模板:

使用 Django 和 HTML Over-the-Wire 构建 SPAs 的第四章/初始模板

完成的代码可以在以下存储库中找到:

使用 Django 和 HTML Over-the-Wire 构建 SPAs 的第八章

还建议您访问 Stimulus 的官方文档,以了解更多关于控制器、动作和目标等重要概念的信息:

Stimulus 手册

此外,建议你拥有一个现代版本的 Node.js 以及最新版本的npm。我们将使用它来安装 Stimulus 包,但我们也可以使用 CDN。

CDN 或内容分发网络

CDN 是一组位于世界各地的服务器,它们协同工作以快速有效地向用户交付内容。它与静态内容(如图像、CSS 和 JavaScript)一起使用。

在资源明确之后,我们现在可以开始实现前端的一个更好版本。我们将从安装 Stimulus 并讨论其不同的配置开始。

安装和配置 Stimulus

在你能够使用 Stimulus 之前,你需要下载并安装该框架。如果你不想使事情复杂化,你可以从其 CDN 导入。只需在 HTML 模板中添加以下脚本即可:

<script type="module" src=
https://unpkg.com/@hotwired/stimulus@3.0.1/dist/stimulus.js
> 

如果你选择这种解决方案,你可以忽略本节的其余部分。

然而,如果你想下载 Stimulus,这是一个非常好的实践,请注意它可以在npm包中找到,所以让我们用命令来安装它:

npm i @hotwired/stimulus

从这里,你有三种不同的配置可能性:使用 Webpack,使用另一个构建系统,或者使用原生的 JavaScript 模块系统。我们将专注于最后一个选项,使用模块,以简化你的实现并避免增加复杂性:

  1. 将 Stimulus 文件复制到static文件夹内的一个文件夹中,例如,在static/js/vendors/路径下:

    mkdir -p static/js/vendors/
    cp node_modules/@hotwired/stimulus/dist/stimulus. Js static/js/vendors/  
    
  2. 我们创建一个名为main.js的 JavaScript 文件,该文件将包含所有未来的前端逻辑和导入(包括 Stimulus):

    touch static/js/main.js
    
  3. 在我们刚刚创建的文件中,main.js,我们将导入 Stimulus 并运行它:

    import { Application } from "./vendors/stimulus.js";
    window.Stimulus = Application.start();
    
  4. 最后,我们将 JavaScript 模块导入一个将在应用程序的主要 HTML 模板中存在的脚本中,以便浏览器可以加载它:

    <script defer type="module" 
      src="img/main.js' %}"></script>
    

Stimulus 已准备就绪!它正在运行,等待我们的事件。

理解所有基本概念的最佳方式是创建一个简单的应用程序。正如我们在介绍中提到的,我们将构建一个具有基本功能的应用程序:将一些文本从小写转换为大写。我们将有一个输入和一个按钮;当按钮被按下时,按钮将在底部显示大写文本。

为了实现目标,我们将了解 Stimulus 的三个基本支柱:控制器动作(不要与后端创建的动作混淆)和目标。我们将首先查看控制器及其在组织逻辑中的重要性。

定义一个控制器

控制器的目的是将 DOM 与 JavaScript 连接起来。它将输入绑定到一个变量,并将我们指示的事件绑定到控制器内部创建的函数。

结构如下:

import { Controller } from "../vendors/stimulus.js".
export default class extends Controller {
   // Variables linked to inputs.
   static targets = [ "input1" ]
   // Constructor or function to be executed when the
   // controller is loaded.
   connect() {
   }
   // Simple function
   myFunction(event) {
   }
}

我们通过importfrom的组合导入了框架自带的Controller类。然后,我们创建了一个扩展Controller的类,并且可以通过导入来访问它(export default)。在内部,我们有一个名为input1的目标示例和两个函数:当 Stimulus 准备就绪时将执行connect()函数,而myFunction()是一个示例函数,它可以被执行。

对于应用程序,我们将在static/js/controllers/transformer_controller.js中创建一个文件,内容如下:

import { Controller } from "../vendors/stimulus.js"
export default class extends Controller {
  static targets = [ "myText" ]
    connect() {
      // Connect to the WebSocket server
        this.myWebSocket = new WebSocket(
          'ws://hello.localhost/ws/example/');
        // Listen for messages from the server
        this.myWebSocket.addEventListener("message",
                                          (event) => {
            // Parse the data received
            const data = JSON.parse(event.data);
            // Renders the HTML received from the Consumer
            const newFragment = document.createRange().
              createContextualFragment(data.html);
            document.querySelector(data.selector).
              replaceChildren(newFragment);
        });
    }
    lowercaseToUppercase(event) {
      event.preventDefault()
      // Prepare the information we will send
      const data = {
          "action": "text in capital letters",
          "data": {
              "text": this.myTextTarget.value
          }
      };
      // Send the data to the server
      this.myWebSocket.send(JSON.stringify(data));
  }
}

如您所见,这是我们在前几章的前端代码中的重构。让我们更仔细地看看每个部分:

  • targets中,我们定义了一个名为myText的变量,它将在使用目标捕获引用部分稍后链接,在那里我们获取输入的值。在控制器内部,我们可以使用this.mytextTarget使用输入。一个目标包含所有输入元素,如value

  • connect()是一个在驱动程序完全挂载时执行的函数。这是一个连接到 WebSocket 服务器并设置消息监听事件的好地方。

  • lowercaseToUppercase(event)是一个将文本发送到后端以转换为大写的函数。在下一节使用动作管理事件中,我们将按钮点击事件链接到该函数。目前,我们只是声明其逻辑。

在声明控制器之后,我们需要在 Stimulus 中注册它并给它一个名字。我们使用以下代码编辑static/js/main.js

import { Application } from "./vendors/stimulus.js";
import TransformerController from 
  "./controllers/transformer_controller.js"; // New line
window.Stimulus = Application.start();
// New line
Stimulus.register("transformer", TransformerController);

基本上,我们已经导入了TransformerController类,并在 Stimulus 中用别名transformer注册了它。

目前,Stimulus 已经注册了一个控制器,但它不知道应该监视 DOM 的哪个区域以及在哪里应用它。让我们来处理这个问题。

在一个新的模板中,例如名为index.html的模板,我们将创建一个简单的表单和一个用于渲染来自后端的所有内容的元素:

<main>
<form>
<input type="text" placeholder="Enter text">
<input type="button" value="Transform">
</form>
<div id="results"></div>
</main>

表单有一个用于写入文本的字段和一个将执行动作的按钮。另一方面,我们包含了一个带有 ID results的 HTML div标签,它将是显示由后端处理并转换为大写的文本的地方

我们将告诉 Stimulus 使用我们选择的 DOM 来使控制器工作。这样做的方式是通过data-controller数据集:

<element data-controller="alias"></element>

在我们的案例中,我们更新了<main>的打开方式:

<main data-controller="transformer">

很简单,不是吗?Stimulus 已经注册了一个控制器,现在知道在哪里应用它。

下一步是指出哪个事件与哪个函数相关联,哪个输入与哪个目标相关联。

使用动作管理事件

动作是 Stimulus 用来将事件链接到控制器函数的结构。它们通过具有以下结构的data-action数据集在 DOM 中声明:

<div data-controller="aliasController">
<button
  data-action=
    "event->aliasController#functionOfTheController"
>Click me!</button>
</div>

它只有在它位于具有相同别名的控制器内部时才会工作;你不能在树外的 DOM 中放置一个动作。

按照示例,我们修改我们的按钮:

<input
  type="button"
  value="Transform"
  data-action="click->transformer#lowercaseToUppercase"
>

让我们分析一下我们用 data-action 做了什么,因为它包含我们必须遵循的格式:

  1. 事件是 click。它可以是任何其他事件,例如,如果我们在一个 HTML <form> 标签中,则可能是 submit 事件,或者是一个 scroll 事件等等。

  2. 在箭头 -> 之后,它作为分隔符,我们指出包含它的控制器别名。

  3. 最后,在 # 之后,它是另一个分隔符,我们指出要执行的函数(lowercaseToUppercase)。

我们简化了事件的定义,但现在它们也会在包含或删除 DOM 元素时自动管理。不仅如此,后端现在还具有添加新事件的超级能力。是的,你没听错,后端可以包含 JavaScript 事件!它们已成为 HTML 中的数据集,我们可以根据需要删除或添加。

完成与 Stimulus 相关的最后一个步骤只剩下:详细说明可以通过目标访问的输入。否则,我们将无法从表单中收集信息。

使用目标捕获引用

刺激通过目标或特殊数据集连接到输入。在内部,Stimulus 创建一个可以在控制器中任何地方使用的变量。例如,我们在 DOM 中定义了一个名为 name 的别名:

<div data-controller="aliasController">
<input type="text" data-aliasController-target="name">
</div>

当在控制器中时,我们定义以下内容:

static targets = [ "name" ]

从这里,我可以在以下任何函数/方法中调用目标:

this.nameTarget

如您所见,别名与目标文本连接在一起。

在我们正在开发的应用程序中,我们使用名称 myText 定义了目标:

static targets = [ "myText" ]

我们按照以下方式更新输入的 DOM:

<input type="text" data-transformer-target="myText" 
  placeholder="Enter text">

整个前端已经准备就绪。我们已经安装了 Stimulus,创建了一个控制器,并定义了一个触发动作的动作和一个收集输入文本的目标。我们只需要在消费者中定义功能。我们转向 Django。

一个将文本转换为上档字母的应用程序

我们已经使用 Stimulus 简化了前端,安装、配置和实现了这个出色框架提供的工具。然而,在将文本从小写转换为上档的应用程序中,我们还有最后一个步骤:在消费者中实现后端。

使用以下代码编辑 app/app_template/consumers.py

from channels.generic.websocket import JsonWebsocketConsumer
from django.template.loader import render_to_string 
class ExampleConsumer(JsonWebsocketConsumer): 
    def connect(self):
        """Event when client connects"""
        # Accept the connection
        self.accept()
    def receive_json(self, data_received):
        # Get the data
        data = data_received['data']
        # Depending on the action we will do one task or
        # another.
        match data_received['action']:
            case 'text in capital letters':
                self.send_uppercase(data)
    def send_uppercase(self, data):
        """Event: Send html to client"""
        self.send_json( {
                'selector': '#results',
                'html': data["text"]. upper(),
            })

代码如此简单,看起来就像属于第一章节。我们已经删除了 actions.py 文件和一些其他元素,因为我们只寻找使它工作的最小必要部分。

让我们回顾一下信息进入后端、转换和返回的地方:

  • 前端的信息传递到 receive_json,然后通过执行 self.send_uppercase(data) 函数接收 'text in capital letters' 动作。

  • self.send_uppercase(data) 将文本转换为上档并发送信息到前端,特别是到 #results 选择器。

是时候测试一切是否正常工作了。我们启动 Docker 并访问http://hello.localhost。输入内容并点击转换按钮。

图 8.1 – 我们测试了应用程序通过将小写字母转换为大写字母来工作

图 8.1 – 我们测试了应用程序通过将小写字母转换为大写字母来工作

在底部,文本将以大写形式显示——我们做到了!

我们甚至可以进一步改进它。在按按钮和显示最终结果之间的本地延迟是可以忽略不计的,在我的情况下,是 0.002 秒。我们可以将input事件整合到输入中,以便我们在输入时就能看到结果,给人一种没有明显延迟的感觉:

<input
  type="text"
  data-transformer-target="myText"
  placeholder="Enter text"
  data-action="input->transformer#lowercaseToUppercase"
>

通过这个小优化,我们可以得出结论,应用程序的后端实现已经完成。

你可能会想将 Stimulus 应用到前几章的示例中——我只能告诉你:继续吧。这样会更整洁,你会对 Stimulus 了解得更多,而且维护起来会更简单。

摘要

我们使用 Django 学习 HTML 的旅程就此结束。现在我们能够通过将所有逻辑集中在后端来实时创建单页应用(SPAs),避免了重复任务,如验证或 HTML 结构。我们减轻了前端的大负担;现在它只需要处理事件或动画,多亏了 Stimulus 控制器和来自数据集的内部自动化。

我很想告诉你,你已经知道你需要知道的一切,但旅程还在继续。这本书只是一个起点。你面前还有许多工作要做:练习,将 Stimulus 应用到你的工作流程中(或任何其他类似框架),解决任何 SPA 典型的微小困难(例如,当用户在历史记录中点击后退按钮时),探索其他相关协议,如服务器端事件,培训你的同事,说服你的老板,定义后端和前端之间的界限(任何网络开发者的无限斗争),甚至采用其他框架。限制由你自己设定。

能够与你们一起加入这场实时 Python 冒险,我感到非常高兴。我喜欢写每一行代码,准备每一个示例。谢谢。

我只能对你说:我对你寄予厚望。

WebSocket.close()

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的工具来帮助你规划个人发展并推进你的职业生涯。更多信息,请访问我们的网站。

第九章:为什么订阅?

  • 通过来自 4,000 多名行业专业人士的实用电子书和视频,节省学习时间,增加编码时间。

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

你知道吗?Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件。你可以在 packt.com 升级到电子书版本,作为印刷书客户,你有权获得电子书副本的折扣。如需了解更多详情,请联系我们customercare@packtpub.com

在 www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的这些其他书籍也感兴趣:

成为企业级 Django 开发者

Michael Dinder

ISBN: 978-1-80107-363-9

使用 Django 开发企业级应用,以帮助扩展你的业务。

了解将原型项目扩展到生产环境所需的步骤和工具,而无需深入研究特定技术。

探索 Django 的核心组件以及如何以不同的方式使用它们来满足你的应用需求。

了解 Django 如何让你构建 RESTful API。

使用 Django 和 Python 从旧数据库系统提取、解析和迁移数据到新系统。

使用 Django 进行 Web 开发

Chris Guest | Subhash Sundaravadivelu | Ben Shaw | Andrew Bird | Saurabh Badhwar

ISBN: 978-1-83921-250-5

  • 创建一个新的应用并添加模型来描述你的数据。

  • 使用视图和模板来控制行为和外观。

  • 通过身份验证和权限实现访问控制。

  • 开发实用的网页表单,添加如文件上传等功能。

  • 开发一个 RESTful API 和与之通信的 JavaScript 代码。

  • 连接到数据库,如 PostgreSQL。

Packt 正在寻找像你这样的作者

如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们将见解分享给全球技术社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交您自己的想法。

嗨!

我是 Andros Fenollosa Hurtado,著有《Building SPAs with Django and HTML Over the Wire》,我真心希望您喜欢阅读这本书,并发现它对提高您使用 Django、Python 和 WebSockets 进行 Web 开发的生产力和效率很有帮助。

如果您能在亚马逊上留下对这本书的评论,分享您的想法,这将对我(以及其他潜在读者!)真的非常有帮助!

点击下面的链接或扫描二维码留下您的评论:

packt.link/r/1803240199

二维码

您的评论将帮助我们了解这本书哪些地方做得好,哪些地方可以改进以供未来版本使用,所以这真的非常感谢。

祝好,

作者签名

posted @ 2025-09-18 14:34  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报