Sequelize-Node-应用提升指南-全-

Sequelize Node 应用提升指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

从在跑道上闲置到在天空中飞行,本书将向您介绍使用 Sequelize 和 MySQL 对 Node.js 应用程序进行数据库交互的世界,从生成架构和适合航空公司到在云端部署预订航班的应用程序。

涵盖了诸如事件生命周期、关联、事务和连接池等概念,以帮助您在开发下一个应用程序时从开始到结束。本书结束时,您将能够熟练并自信地使用 Sequelize 在数据库管理系统和 Node.js 应用程序之间创建、删除和转换数据。

本书面向的对象

本书面向初学者到中级 JavaScript 开发者,他们对于创建 Node.js 应用程序是新手,并希望将其数据库附加到他们的 Web 应用程序中。拥有 SQL 知识是加分项,但不是理解本书内容的先决条件。

本书涵盖的内容

第一章, Sequelize 和 Node.js 中 ORM 的介绍,涵盖了为本书课程安装必要的先决条件。

第二章, 定义和使用 Sequelize 模型,涵盖了绘制数据库架构以及读取或写入它的内容。

第三章, 验证模型,介绍了如何确保模型数据的完整性。

第四章, 关联模型,将帮助您了解创建模型之间关系的基本知识和优势。

第五章, 将钩子和生命周期事件添加到您的模型中,将通过实际应用示例介绍生命周期事件的操作顺序。

第六章, 使用 Sequelize 实现事务,介绍了使用不同的隔离和锁定级别封装事务查询。

第七章, 处理自定义、JSON 和 Blob 数据类型,介绍了在关系型数据库中使用文档化和杂项存储。

第八章, 记录和监控您的应用程序,帮助您识别应用程序中的问题和瓶颈。

第九章, 使用和创建适配器,介绍了如何扩展、添加和集成 Sequelize 库以创建新的工具和平台。

第十章, 部署 Sequelize 应用程序,介绍了如何将 Avalon Airlines 项目部署到云应用平台,如 Heroku。

为了最大限度地利用本书

所有代码示例均已在 macOS 和 Linux 上使用 Node.js 16、MySQL 5.7 和 Sequelize 6 进行测试。然而,代码库仍然应该与未来的版本发布兼容。

本书涵盖的软件/硬件 操作系统要求
Node.js 16 Windows、macOS 或 Linux
MySQL 5.7 Windows, macOS, 或 Linux
Sequelize 6 Windows, macOS, 或 Linux

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

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/Supercharging-Node.js-Application-with-Sequelize。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

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

使用的约定

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

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

models.sequelize.sync({
    force: true,
    logging: false
})

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

// INT(4)
var unsignedInteger = DataTypes.NUMBER({
    length: 4,
    zerofill: false,
    unsigned: true,
});

任何命令行输入或输出都应如下编写:

sudo apt-get update
sudo apt install mysql-server

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“我们将想要选择开发者默认安装所有产品选项。”

小贴士或重要提示

看起来是这样的。

联系我们

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

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

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

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

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

分享您的想法

一旦您阅读了用 Sequelize 提升 Node.js 应用程序性能,我们非常乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

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

第一部分 – 安装、配置和基础知识

在本部分,您将学习如何为您的操作系统安装和配置 Sequelize,以及如何从数据库中插入、删除、更新和查询数据。

本部分包括以下章节:

  • 第一章Node.js 中的 Sequelize 和 ORM 简介

  • 第二章定义和使用 Sequelize 模型

第一章:Sequelize 和 Node.js 中 ORM 的简介

管理数据库驱动程序、管理模式、维护业务流程以及验证数据可能对任何程序员来说都是一项艰巨的任务。随着业务需求的不断变化,将业务逻辑组织到数据库模型中可能会变得繁琐。这通常意味着程序员需要找到所有适用的引用并手动更新查询。这可能对项目和程序员来说都是一项昂贵的操作;如果没有适当的测试,修改可能会导致应用程序中的错误或错误的逻辑,使程序员、业务和客户陷入混乱的状态。

本书将指导您通过在 Node.js 运行时环境中使用 Node.js 运行环境,通过对象关系映射(ORM)框架安装、构建、维护、升级、扩展、查询和应用数据库模式的过程。您可以从头到尾顺序阅读本书,如果您更有经验,可以直接阅读您感兴趣的部分章节。由于我们将从头开始创建整个应用程序,因此每个章节都是相互补充的。然而,更有经验的程序员可以在章节之间跳转,理解到他们的数据模型和章节中展示的内容之间可能存在“差距”。无论您的数据结构如何,每个章节中教授的概念和方法都将适用。

本章的目标是帮助您熟悉 Sequelize 是什么以及使用 Sequelize 提供了哪些功能。我们将介绍安装相关库、框架、运行时引擎和数据库管理系统DBMS)的必要先决步骤。到本章结束时,您将获得在 Node.js 运行时下使用 Sequelize 从零开始安装、配置和运行应用程序的知识和技能集。

本书的第一章将涵盖以下主题:

  • 介绍 Sequelize

  • 使用 Sequelize 而不是其他替代方案的优势

  • 安装必要的应用程序、框架和工具以帮助您入门

  • 在 Express 应用程序中配置 Sequelize

技术要求

在我们开始使用 Sequelize 开发应用程序的旅程之前,有一些先决条件。我们需要安装以下内容:

  • 一个数据库管理系统,如 MySQL

  • Node.js 运行时库

  • 几个 Node.js 包:Sequelize、Express 和一个 MySQL 驱动

介绍 Sequelize

Sequelize(也称为SequelizeJS)是一个 ORM 框架,它帮助将 Node.js 应用程序连接并对应到数据库。Sequelize 自 2010 年起由 Sascha Depold 开发,并在财富 100 强公司中得到广泛使用。多年来,该框架在 GitHub 上已拥有近 25,000 个星标,超过 900 位贡献者,并被 300,000 多个开源项目使用。Sequelize 在性能和安全方面经过了超过十年的实战考验,即使在一年中流量最高的时段,也为主要零售店和网络机构(如沃尔玛和 Bitnami)提供了无问题的服务。

最初只是一个硕士论文的项目,最终成为了 Node.js 生态系统的一个主要组成部分。

注意

ORM(对象关系映射)是一种使用面向对象OO)装饰和模式将数据库结构和信息关联起来的方法。ORM 的目的在于帮助缓解 DBMS 之间的差异,并提供一种形式上的抽象,以便更人性地查询和操作数据。通常,ORM 还会附带一些辅助函数,以帮助管理连接状态、数据预验证和工作流程。

该框架遵循基于 Promise的方法,允许程序员异步调用数据。基于 Promise 的方法提供了一种更方便的方式来管理应用程序中返回的值或错误,而无需等待结果立即返回。要了解更多关于 Promise 及其编程方法的信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

什么是异步?

将异步视为一种无需等待响应即可继续执行其他任务的方式。当你给某人发短信时,你不必等待他们的回复就可以继续你的日常生活。发送消息后,你通常不会关注通信,直到你收到有回复或消息发送失败的信号。

目前,Sequelize 支持以下数据库管理系统:MySQL、MariaDB、Postgres、Microsoft SQL ServerMSSQL)、Snowflake、Database 2DB2)和 SQLite。ORM 不仅提供数据库的连接器,通常还提供以下功能:

  • 用于迁移模式和数据的工具

  • 适配器/插件支持

  • 连接池

  • 数据预加载

  • 管理事务

现在我们已经了解了 Sequelize 是什么以及其基本功能,我们将探讨为什么我们应该使用 Sequelize 这样的 ORM 而不是像数据访问对象DAOs)或直接查询数据库这样的替代方法。其中一些有利的特性包括能够在事务中处理和组织查询,或将模式更改迁移到数据库。

使用 Sequelize 相比其他替代方案的优势

从您的应用程序查询数据库有许多替代方法。有 ORM、DAO、原始数据库驱动程序等等。每种方法都有其优缺点,并满足不同的编程风格和约定。通常,那些喜欢约定胜于配置的人倾向于使用 ORM,而那些喜欢配置的人则倾向于使用 DAO 框架或原始数据库驱动程序。

ORM 可以处理数据验证,类似于 DAO,具有从数据库使用驱动程序读取和写入的附加功能。使用 ORM,您不需要手动管理查询语句,这可能会比 DAO 或原始连接方法节省您的时间。

注意

ORM 并非与 DAO 互斥。您可以将 DAO 视为是显式的,而不是隐式和假设性的。DAO 只提供数据的一个接口。它不涉及您如何/在哪里读取或写入数据(数据库驱动程序),也不会关心数据完整性,除非应用程序在 DAO 范围之外手动调用某种形式的数据验证。

当使用如 Sequelize 这样的 ORM 时,您将获得以下功能而无需任何额外代码:

  • 事务处理

  • 连接池

  • 模型/数据验证

  • 数据完整性(超出 DBMS 的外键FKs)、唯一约束等范围)

  • 预加载

  • 图形迁移和级联

  • 乐观锁定

使用 DAO 或原始数据库驱动程序将放弃这些功能,您将不得不自己构建这些解决方案。使用如 Sequelize 这样的 ORM 将帮助您更高效、更有效地构建项目。

到目前为止,我们已经涵盖了 Sequelize 的是什么为什么;现在,我们将介绍安装应用程序所需必要先决条件的如何

安装必要的应用程序、框架和工具以帮助您开始

我们的应用程序将要求客户从集中式来源查看信息,并且我们需要捕获他们输入到我们数据库中的信息。通常,客户可以通过安装在他们机器上的应用程序查看您的产品/服务,或者他们可以使用浏览器访问我们的网站。Node.js 是构建网络应用程序的一个好选择,因为本书将围绕它展开,这得益于其中央处理单元CPU)限制以及在前端开发(向最终用户显示的内容)和后端开发(最终用户看不到但仍然调用的内容)之间轻松切换上下文的能力,因为 Node.js 是 JavaScript。为了开始,我们需要安装以下应用程序/程序:

  • DBMS(我们将安装 MySQL)

  • Node.js 运行时

  • Sequelize 和 Express

安装 MySQL

下一个部分将介绍在三种不同的操作系统发行版上安装 MySQL 的过程:Microsoft Windows、macOS 和 Linux。MySQL 被选择是因为其安装简单(无需配置或访问控制列表ACLs))。不要让这些点阻止您使用不同的数据库。就大部分而言,Sequelize 应该能够优雅地将一个 DBMS 转换为另一个,本书的大部分内容将使用通用/标准的结构化查询语言SQL)方法。

Windows

Microsoft Windows 的 MySQL 安装程序可在此处找到:

dev.mysql.com/downloads/mysql/5.7.xhtml

注意

默认版本为 8.0.26。本书使用版本 5.7,但只要 Node.js MySQL 驱动程序与该版本兼容,MySQL 的其他版本也应能适当工作。

下载并打开安装程序应用程序后,您将看到选择安装类型屏幕。我们将想要选择开发者默认安装所有产品选项,如图所示:

图 1.1 – Windows MySQL 安装程序:选择安装类型

图 1.1 – Windows MySQL 安装程序:选择安装类型

如果您的计算机上已安装 Python 或 Visual Studio,您可能会遇到一个检查要求步骤(见 图 1.2)。如果您使用 Visual Studio 作为您的集成开发环境IDE),则可以安装必要的软件产品,但这不是必需的。在整个项目过程中,您可能会遇到用 Python 编写的工具,这些工具与您的数据库进行交互(例如,大多数与数据科学相关的库/框架)。通过选择以下屏幕截图所示的Connector/Python选项,我们可以避免未来可能出现的潜在问题:

图 1.2 – Windows MySQL 安装程序:检查要求

图 1.2 – Windows MySQL 安装程序:检查要求

下一个步骤应该是下载步骤。本书内容所需的主要产品列在这里:

  • MySQL 服务器

  • MySQL Workbench(用于数据库的图形用户界面GUI))

  • MySQL Shell

您可以在以下屏幕截图中看到上述产品:

图 1.3 – Windows MySQL 安装程序:下载

图 1.3 – Windows MySQL 安装程序:下载

注意

如果您是 MySQL 的新用户,下载MySQL 文档示例和示例包可能是个好主意。

在我们完成下载我们的软件包后,我们将为每个适用的选定产品(例如,MySQL 服务器示例和示例)输入我们的配置详细信息。对于大多数配置设置,我们将使用默认值;然而,将有一些步骤需要您的干预。您可以在以下屏幕截图中查看此概述:

图 1.4 – Windows MySQL 安装程序:类型和网络

图 1.4 – Windows MySQL 安装程序:类型和网络

MySQL 服务器 配置向导中,我们希望以下设置(如图 图 1.4 所示):

  • 配置类型开发计算机

  • TCP/IP:已勾选

  • 3306

  • 打开 Windows 防火墙端口以进行网络访问:可选

MySQL 服务器配置步骤的下一部分是声明您的 MySQL 根密码和用户账户。请确保将此信息保存在安全的地方,以防在项目过程中遇到管理问题。如果您忘记了 MySQL 根密码,有几种方法可以重置密码,如以下所述:dev.mysql.com/doc/mysql-windows-excerpt/5.7/en/resetting-permissions-windows.xhtml

对于设置具有角色的 MySQL 用户账户,您将看到以下 账户和角色 屏幕:

图 1.5 – Windows MySQL 安装程序:账户和角色

图 1.5 – Windows MySQL 安装程序:账户和角色

MySQL 用户账户 部分中,您需要点击 添加用户 按钮(如图 图 1.5 所示,位于窗口右侧附近)并输入一个您将记住的用户名和密码,以便我们在初始化 Node.js 应用程序时使用。当您完成添加适当的根密码和 MySQL 用户账户后,我们可以继续下一步。

接下来,安装过程将提供一个 将 MySQL 服务器配置为 Windows 服务 选项,如图中所示。Windows 服务 是一个 进程控制系统PCS),它还将编排后台进程(在 Unix/Linux 世界中,这些被称为 daemons):

图 1.6 – Windows MySQL 安装程序:Windows 服务

图 1.6 – Windows MySQL 安装程序:Windows 服务

我们希望确保以下参数已配置(如图 图 1.6 所示):

  • 将 MySQL 服务器配置为 Windows 服务:已勾选

  • 在系统启动时启动 MySQL 服务器:已勾选

  • **在 运行 Windows 服务作为... 部分下选择 标准系统账户

点击 下一步 > 以应用 MySQL 服务器的配置。如果您之前选择了要安装的附加软件包,您可能会遇到额外的屏幕,要求您提供更多的配置设置和参数。

备注

如果您在上一节中选择了MySQL 路由器包,安装过程将要求您提供有关您如何设置集群环境的信息。除非您是数据库管理员或您正在设置生产环境,否则不建议安装此包。只需取消选择为 InnoDB 集群启动 MySQL 路由器选项,然后点击完成以在不安装 MySQL 集群环境的情况下继续。

如果选择了安装示例和示例包,我们将看到一个屏幕,允许我们输入我们的 MySQL 用户名和密码。您可以使用您的root 凭证作为用户名和密码输入字段的输入,并点击下一步 >按钮继续。屏幕的概述如下截图所示:

图 1.7 – Windows MySQL 安装程序:连接到服务器

图 1.7 – Windows MySQL 安装程序:连接到服务器

macOS

在 macOS 上安装 MySQL 有几种方法。第一种是从磁盘镜像DMG)文件下载并安装 MySQL,另一种方法是使用包管理器,如 Homebrew。我们将探讨这两种选项。

从磁盘镜像安装

您可以从以下 URL 找到适当的磁盘镜像:dev.mysql.com/downloads/mysql/(x86 用于英特尔 CPU,高级精简指令集ARM)用于 M1 CPU)。

注意

如果您找不到 MySQL 的 5.7 版本,您可以从 MySQL 的存档链接中找到适当的 DMG 文件:downloads.mysql.com/archives/community

然而,macOS 安装包可能无法下载最新 5.7 版本。在撰写本书时,版本 5.7.34、5.7.33 和 5.7.32 作为 DMG 包不可用(5.7.31 可以下载)。任何适用的 5.7 版本都应与本书的说明和安装程序兼容。

如果在安装过程中被要求安装偏好面板,我们建议您这样做。否则,我们需要查阅位于dev.mysql.com/doc/refman/5.7/en/macos-installation-launchd.xhtml安装 MySQL 启动守护进程页面。

下载并打开 DMG 文件后,我们将想要打开pkg)文件,这将启动我们的安装过程。根据您的 macOS 版本,您可能会看到一个“[包名]”无法打开,因为苹果无法检查其恶意软件的屏幕,如下所示:

图 1.8 – 苹果无法识别该包的恶意性

图 1.8 – 苹果无法识别该包的恶意性

如果这种情况适用于你,请转到苹果 | 安全与隐私,窗口中应该在“mysql….pkg”由于不是来自已识别的开发者而被阻止使用旁边有一个“无论如何打开”按钮,如下面的屏幕截图所示:

图 1.9 – 绕过未识别的软件包安装

图 1.9 – 绕过未识别的软件包安装

一旦安装包再次打开,你可能会收到来自苹果的另一个警告。点击打开以继续安装过程。在继续并阅读软件许可协议SLA)后,你可以选择默认的安装位置。点击安装可能会提示输入你的管理员密码,如下面的屏幕截图所示:

图 1.10 – MySQL 安装请求管理员权限

图 1.10 – MySQL 安装请求管理员权限

一旦 MySQL 安装程序完成,将出现一个带有临时密码的警告对话框。以下是一个示例。确保在登录 MySQL 服务器时记下临时密码:

图 1.11 – MySQL 安装提供临时 root 密码

图 1.11 – MySQL 安装提供临时 root 密码

从 Homebrew 安装

使用 Homebrew 而不是传统的软件包安装程序可以帮助你保持软件包更新,无需手动干预,同时验证软件包安装和二进制文件。要通过 Homebrew 安装 MySQL,我们需要在我们的本地机器上安装 Homebrew。在终端(位于应用程序 > 实用工具)中,只需输入以下命令:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

注意

在运行外部脚本命令之前,总是检查脚本内容是一个好主意。一个网页可以重定向到任何地方,包括可能导致数据泄露或更恶劣的恶意脚本。

在安装 Homebrew 时,你可能会遇到以下消息:

==> Checking for `sudo` access (which may request your password)

你可以在下面的屏幕截图中看到这个示例:

图 1.12 – 在 macOS 上安装 Homebrew

图 1.12 – 在 macOS 上安装 Homebrew

你可以在这里输入密码,或者在安装 Homebrew 之前运行sudo <anything>(例如,sudo ls),输入密码,然后运行安装命令。用户在继续之前必须具有管理员访问权限。

对于这本书,我们将安装 MySQL 版本 5.7。如前所述,MySQL 的其他版本应该与本书的代码库兼容。要明确安装 5.7 版本,请运行以下命令:

brew install mysql@5.7

为了正确设置你的实例,可能需要运行额外的步骤和命令,如下面的屏幕截图所示。本书的内容不需要库/头文件进行编译,也不需要配置 pkg-config。一般来说,建议运行 mysql_secure_installation 并按照提示添加根密码,但这不是必需的:

图 1.13 – 在 macOS 上使用 Homebrew 安装 MySQL

图 1.13 – 在 macOS 上使用 Homebrew 安装 MySQL

接下来,我们需要一种管理我们的 MySQL 服务的方法。这里概述了两种可供选择的方法:

  • 手动创建启动守护进程配置文件。有关如何实现此操作的更多信息,请参阅此处:dev.mysql.com/doc/refman/5.7/en/macos-installation-launchd.xhtml

  • 我们可以使用一个名为 services 的 Homebrew 扩展,通过执行以下命令来自动管理启动配置:

    brew tap homebrew/services
    

为了启动 MySQL 服务,我们需要运行以下命令:

brew services start mysql@5.7

如果你更喜欢使用图形界面来管理你的服务,有一个名为 brew-services-menubar 的应用程序可以通过 Homebrew 的 Cask 扩展安装,如下面的代码片段所示:

brew install --cask brewservicesmenubar

注意

如果你更喜欢在交互或查询数据库时使用图形界面,有一个名为 Sequel Pro 的免费应用程序可供下载,链接如下:

www.sequelpro.com/

Linux

Linux 有许多发行版;对于本书,我们将使用 Ubuntu(任何 Debian 发行版都应适用,使用相同的命令)。如果你使用的是不同的发行版,请参考此页面获取如何为你的操作系统安装 MySQL 的说明:dev.mysql.com/doc/refman/5.7/en/linux-installation.xhtml

在终端中运行以下命令(这些命令也在下面的屏幕截图中有显示):

sudo apt-get update
sudo apt install mysql-server

图 1.14 – 在 Ubuntu 上安装 MySQL 服务器

图 1.14 – 在 Ubuntu 上安装 MySQL 服务器

MySQL 安装完成后,我们需要初始化一个数据库来存储我们所有模型的结构和信息。一些 ORM 和 DBMS 将数据库称为“模式”(不要与模型模式混淆,在 Sequelize 中被称为“属性”)。

创建数据库

现在我们已经在我们本地机器上完成了 MySQL 数据库管理系统引擎的安装,我们可以开始创建一些表来构建数据库。在创建表之前,我们需要了解 MySQL 的各种引擎类型。幸运的是,以下内容适用于所有操作系统:

默认情况下,MySQL 将创建 InnoDB 数据库类型(或在 MySQL 术语中,引擎)。数据库引擎与 MySQL 中的数据库表相关联(而不是整个数据库)。当你知道在无约束的读取密集型表(例如,新闻文章)和写入密集型表(例如,聊天室)之间的权衡时,这非常有用。为了简洁起见,我们将简要介绍以下三个主要数据库引擎:

  • InnoDB:一个具有事务查询和 FK 支持的数据库引擎。事务查询对于执行一个查询,或多个查询,并保证原子性非常有用。我们将在后面的章节中进一步详细介绍事务和 FK。

  • MyISAM:如果你的数据库操作主要是读取相关且你不需要任何数据约束,那么这将是一个首选的数据库引擎。

  • HEAP:这些表中的数据存储在机器的内存中。如果你需要快速查询临时数据,这个数据库引擎非常有用。MySQL 不会为你管理内存分配,因此记住在不再使用表时删除它们(并且数据适合机器的可用内存)非常重要。

注意

你可以通过在 MySQL 客户端中输入以下命令来检查你本地 MySQL 服务器的默认引擎类型:SELECT @@default_storage_engine;

你可以跳过这一节并使用 Sequelize 的 db:create 命令,只要相应的 MySQL 用户具有适当的权限。为了熟悉终端,我们将使用命令行创建数据库,如下一截图所示。

使用以下命令登录到 MySQL 服务器(你可能需要输入密码,或者需要额外的 -p 参数来输入密码):

mysql --user=root

我们可以在 MySQL 客户端命令提示符中执行以下 SQL 命令来创建我们的数据库:

CREATE DATABASE airline;

对于 Windows 用户

大多数这些命令都可以通过命令提示符或 PowerShell 应用程序执行。这些应用程序可以通过 开始 菜单访问(例如,开始 > 所有程序 > 附件 > Windows PowerShell)。

图 1.15 – 创建数据库

图 1.15 – 创建数据库

如果你使用的是 Windows 机器,你可以使用任何你选择的终端应用程序(命令提示符、PowerShell 等),或者你可以使用 MySQL Workbench,如下面的截图所示,我们在上一节中已经安装了它:

图 1.16 – MySQL Workbench:创建数据库

图 1.16 – MySQL Workbench:创建数据库

注意

要使用 MySQL Workbench 执行查询,查询工具栏中有一个 闪电 图标(图标通常在 保存 图标旁边)。你的查询结果将出现在屏幕底部的 输出 部分。

安装 Node.js

在撰写本书时,Node.js 的长期支持LTS)版本是 16。在本书中,我们将使用这个版本的 Node.js,但代码库应该仍然可以使用其他版本无问题执行。所有相应的操作系统安装的 Node.js 都可以在这里找到:nodejs.org/en/download/

注意

如果 Node.js 的长期支持(LTS)版本不再是 16,而您想使用本书中相同的版本,您可以从这里下载以前的 Node.js 版本:nodejs.org/en/download/releases/

对于在一台机器上管理多个 Node.js 版本,有一个名为Node 版本管理器NVM)的应用程序可以处理和维护同一台机器上的多个 Node.js 版本。有关更多信息,您可以访问他们的仓库:github.com/nvm-sh/nvm

Windows

在下载并打开 Node.js Windows 安装程序后,我们将看到一个如下所示的屏幕提示:

图 1.17 – Windows Node.js 安装程序:目标文件夹

图 1.17 – Windows Node.js 安装程序:目标文件夹

点击下一步将带我们进入安装的自定义设置步骤。请确保您正在安装/配置以下内容:

  • Node.js 运行时

  • npm 包管理器

  • 添加到 PATH

您可以在此处查看此屏幕的概述:

图 1.18 – Windows Node.js 安装程序:自定义设置

图 1.18 – Windows Node.js 安装程序:自定义设置

在完成自定义设置步骤后,我们将进入原生模块工具部分。默认情况下,安装必要工具的复选框未勾选。为了开发目的,我们希望确保自动安装选项已勾选,如下一截图所示:

图 1.19 – Windows Node.js 安装程序:原生模块工具

图 1.19 – Windows Node.js 安装程序:原生模块工具

选择自动工具安装将弹出一个 PowerShell 窗口,如下一张截图所示,显示 Chocolatey、.NET 包、Python 依赖项等的安装进度。

图 1.20 – Windows Node.js 安装:附加工具

图 1.20 – Windows Node.js 安装:附加工具

注意

Chocolatey 是 Microsoft Windows 操作系统的包管理器。如果您熟悉 macOS 环境,这类似于 Debian Linux 发行版上的 Homebrew 或 Apt。有关 Chocolatey 的更多信息,请参阅以下链接:chocolatey.org/

macOS

您可以通过其包镜像安装 macOS 上的 Node.js,该镜像位于nodejs.org/en/download/,或者您可以通过运行以下命令使用 Homebrew 安装它:

brew install node@16

为了确认您的机器正在使用正确的“node”二进制文件,我们可以始终通过运行以下命令来检查版本:

node -v

Linux

对于 Ubuntu/Debian Linux 发行版,我们可以使用特定的仓库来安装 Node.js 14,如下面的代码片段所示:

sudo apt update
curl -sL https://deb.nodesource.com/setup_14.x | sudo bash -

在添加了仓库之后,我们可以安装 Node.js 并检查版本,如下所示:

sudo apt -y install nodejs
node -v

到目前为止,我们已经完成了 MySQL 作为我们的数据库管理系统(DBMS)、适用的包管理器和 Node.js 运行时库的安装;现在我们可以开始搭建我们的项目,并安装 Sequelize 和 Express 所需的 Node.js 包。

在 Express 应用程序中配置 Sequelize

在我们安装了我们的开发工具和数据库之后,我们可以开始使用 Sequelize 和Express安装和配置我们的应用程序。Express 是一个针对 Node.js 运行时应用程序的最小化网络框架。我们的 Node.js 应用程序将使用 Sequelize 与数据库进行通信,Express 将把查询结果传递到浏览器。有关 Express 的更多信息以及完整的参考,请参阅此处:expressjs.com

在命令提示符、PowerShell 或终端中,输入以下命令以初始化我们的项目:

mkdir airline
cd airline
npm init -y

这将创建一个名为airline的目录;然后,我们将工作目录更改为airline文件夹,并从npm命令运行初始化脚本将创建一个package.json文件,该文件包含 npm 在此项目上使用的裸配置。之后,我们需要安装我们应用程序所需的最小 Node.js 模块,如下所示:

npm install express sequelize mysql2

这里有一个在线资源,您可以参考它以获取 npm 的完整选项列表:

docs.npmjs.com/cli/v7/commands

Sequelize 有一个伴随的可执行文件,可以帮助我们初始化项目、管理模式更新和处理数据库迁移。我们可以在我们的用户空间中将其作为全局(--location=global)二进制文件安装,通过在终端中输入以下命令:

npm install --location=global sequelize

对于可用的完整命令列表,CLI 内置了文档,可以使用-h--help标志来暴露,如下面的截图所示:

图 1.21 – Sequelize CLI 安装和帮助指南

图 1.21 – Sequelize CLI 安装和帮助指南

下一步是初始化 Sequelize 为我们提供的通用模板。这将生成用于配置、迁移、种子和模型文件的几个目录。以下是执行此操作的代码:

sequelize init

以下列表简要说明了 CLI 在我们项目目录中创建的目录:

  • config:一个包含数据库连接配置文件的目录,sequelize-cli 工具使用此配置文件来迁移模式和数据文件,但这些配置设置也可以用于我们的 Node.js 应用程序。

  • migrations:一个包含 Node.js 文件的目录,其中包含 Sequelize 的指令,用于构建数据库的模式和结构。

  • models:包含 Sequelize 模式定义的 Node.js 文件集合。

  • seeders:类似于 migrations 目录,但不是定义我们的数据库模式,而是定义我们的数据库数据。

现在我们已经建立了应用程序的初始基础,我们可以编辑位于 config/config.json 中的 Sequelize 配置文件。根据您遵循的安装说明,用户名和密码值可能与本书的代码库不同。代码如下所示:

{
  "development": {
    "username": "root",
    "password": null,
    "database": "airline",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "airline",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "airline",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
} 

如果您不想将用户名和密码保存在文件中(这对于生产环境或版本控制仓库来说是个好主意),配置文件有另一种形式,可以接受一个带有连接 mysql://root:password@127.0.0.1:3306/airline 的环境键,如下代码片段所示:

{
  "development": {
    "use_env_variable": "DB_DEV_ENV"
  },
  "test": {
    "use_env_variable": "DB_TEST_ENV"
  },
  "production": {
    "use_env_variable": "DB_PRODUCTION_ENV"
  }
}

如果我们想使用 development 配置,我们的 Node.js 应用程序将知道从名为 DB_DEV_ENV 的环境变量中查找连接参数/URI(您可以使用相同的环境变量用于任何阶段)。有关 Sequelize CLI 的更多选项和配置设置,请参阅此资源:github.com/sequelize/cli/blob/master/docs/README.md

注意

您可以通过设置 NODE_ENV 环境变量在您希望应用程序所处的环境之间切换。默认值是 development,但如果我们想使用我们的 production 环境,我们可以这样设置环境:NODE_ENV=production

将 Sequelize 与 Express 连接

我们现在可以开始构建我们的 Node.js 应用程序,通过在项目目录中创建一个 index.js 文件并在我们选择的 IDE 中打开该文件。让我们先输入以下代码:

const express = require("express");
const app = express();
const models = require("./models");
models.sequelize.sync().then(function () {
    console.log("> database has been synced");
    }).catch(function (err) {
    console.log(" > there was an issue synchronizing the
                    database", err);
});
app.get('/', function (req, res) {
    res.send("Welcome to Avalon Airlines!");
});
app.listen(3000, function () {
    console.log("> express server has started");
});

我们首先通过代码的前两行声明我们的 Express/web 应用程序变量(expressapp)。下一行是调用之前由 Sequelize CLI 创建的 ./models/index.js 文件的快捷方式(我们将在下一章详细介绍该文件)。下一行运行 Sequelize 的 sync() 命令,该命令将通过创建必要的表、索引等来同步您的模型定义与数据库。它还将建立关联/关系,执行与同步相关的钩子/事件等。

sync() 命令提供了几个选项,这些选项作为第一个参数封装在一个对象中,如下所示:

  • force:一个布尔值,将在重新创建之前删除你的数据库表。

  • match:生产环境中的一个 force 选项。

  • logging:一个布尔值或函数值。true(默认值)在执行查询时使用 console.log 进行日志记录。false 将完全禁用,并且可以使用函数将日志和上下文发送到另一个适配器。本书将在后面的章节中详细介绍此选项。

  • schema:一个字符串值,用于定义要操作的数据库。当使用允许通过数据库(MySQL 称为“模式”)以及命名空间(Postgres 称为“模式”)来分离表的数据管理系统(如 Postgres)时很有用。

  • searchPath:一个字符串值,用于定义仅适用于 Postgres 数据库的默认 search_path。此选项与本书的代码库或内容无关。

  • hooks:一个布尔值(默认为 true),用于执行与同步事件相关的多个钩子/事件(beforeSyncafterSyncbeforeBulkSyncafterBulkSync)。设置为 false 将禁用事件的执行。

  • alter:一个具有以下参数的对象:

    • drop:一个布尔值,当 Sequelize 需要在数据库中运行 ALTER 命令时,防止执行任何 drop 语句。

你可以像这样定义这些选项:

models.sequelize.sync({
  force: true,
  logging: false
})

注意

Sequelize 社区不建议在生产环境中将 force 选项设置为 true。这可能会产生意外的后果,例如删除重要的客户/用户信息。force 选项用于当你还在原型化应用程序时,并希望每次迭代都从零开始启动应用程序。

下一个命令,app.get(...),指示 Express 框架将我们的 web 应用程序的根路径 "/" 路由到作用域函数(在这种情况下,我们向浏览器发送文本,如 图 1.22 所示)。之后,我们通过调用 app.listen(...) 启动 Express 服务器,这将告诉我们的应用程序监听 3000 端口,可以通过 http://localhost:3000http://127.0.0.1:3000 访问,具体取决于你的网络接口设置。要启动我们的应用程序,我们可以在终端/PowerShell 中运行以下命令:

node index.js

你应该在屏幕上看到以下显示的文本:

  • Express 已启动

  • 执行了一个 SQL 查询

  • 数据库已同步

注意

Sequelize 将自动执行一个 SELECT 1+1 AS result 查询,作为检查数据库连接健康状态的方法。并非所有数据库管理系统(DBMS)都提供发送 ping 数据包来检查连接是否成功的方法。

现在,当你打开浏览器并访问之前提到的 URL 时,你应该看到与这里显示的页面类似的页面:

图 1.22 – 欢迎页面

图 1.22 – 欢迎页面

每次我们对应用程序进行更改时,我们都需要在终端中终止当前进程(Ctrl + C)。这将向进程发送SIGINT信号,该信号将向进程发送中断信号,以便开始清理然后退出/停止。为了避免每次更改后都需要手动重新启动我们的进程,我们可以安装一个名为 Nodemon 的独立进程来帮助我们完成这项工作(更多信息请见:nodemon.io/)。

可以通过运行以下命令将 Nodemon 安装为全局二进制文件:

npm install -g nodemon

您可以通过输入以下内容来确认安装是否成功:

nodemon index.js

这应该会同时启动我们的 Node.js 应用程序并监视我们项目目录中的更改文件。一旦我们对项目进行了修改,我们应该看到 Nodemon 自动重启我们的进程,如下面的截图所示:

图 1.23 – Nodemon 自动重启应用程序

图 1.23 – Nodemon 自动重启应用程序

本章的最后一步是对我们的package.json文件进行一些调整,如下所示:

  • "name": "airline,"行下添加"private": true。这个调整将防止我们(或团队中的任何人)将我们的项目发布到公共 npm 注册表。

  • 查找scripts对象,并将其中内容替换为"start": "nodemon index.js"。这样我们就可以通过运行以下命令来启动我们的应用程序:

    npm run start
    

最终的package.json文件应类似于以下内容:

{
  "name": "airline",
  "private": true,
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "⁴.17.1",
    "mysql2": "².3.0",
    "sequelize": "⁶.6.5"
  }
}

摘要

在本章中,我们介绍了使用 ORM 的好处以及 Sequelize 能提供什么。我们学习了如何设置我们的开发/本地环境以运行 DBMS(MySQL)和 Node.js 运行时。然后我们使用 npm 和 Sequelize CLI 构建了一个项目,并将 Sequelize 库与 Express 网络框架集成。

在下一章中,我们将开始向我们的数据库中插入数据并定义 Sequelize 模型。

第二章:定义和使用 Sequelize 模型

对于我们在上一章中介绍的 Avalon Airlines 项目,我们需要指导我们的应用程序如何定义我们数据库的图。数据库可以有多种角色和应用,但只有一个目的,那就是组织我们的数据(存储是文件系统的职责)。在我们开始在 Node.js 应用程序中定义模型之前,我们需要从项目角度考虑我们的业务逻辑和模型(每个项目都会有不同的要求)。大多数项目都会以一种方式结构化其图,将 组织(例如,客户、员工、供应商和公司)以及 事物(如产品、飞机和交易收据)进行分类。

snake_casePascalCase 模式)。模型之间的关系或关联将由 Sequelize 自动创建和管理。还可以建立业务逻辑工作流程,这样您就不必记住像 如果客户取消了行程,则删除客户的登机牌 这样的工作流程。这部分将由一个组织良好的地方处理,而不是在每个取消行程的代码部分调用 RemoveBoardingPass(...)(无论该方法是从客户、员工等调用)。本章将教会您如何定义和同步您的模型与数据库,以及如何使用 Sequelize 将数据应用到 Node.js 运行时应用程序。这将是我们操作 Sequelize 的初始基础。

本章将向您介绍以下概念:

  • 定义数据库模型

  • 探索各种 Sequelize 数据类型及其使用时机

  • 将 Sequelize 中的图和数据进行迁移到数据库

  • 使用 Sequelize 操作和查询数据

  • 定义模型的 Sequelize 高级选项

技术要求

您可以在 GitHub 上找到本章中包含的代码文件,地址为 github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch2

为数据库定义模型

在本节中,我们将简要概述我们项目的要求,并确定我们需要定义哪些类型的模型。之后,我们将从 Sequelize 命令行界面CLI)工具运行一个脚本生成命令,并检查模型定义的基本结构。

对于 Avalon Airlines,我们将从以下 组织事物 开始建模:

  • 飞机

  • 客户

  • 飞行计划

  • 登机牌

每个模型将在数据库中拥有自己的表。我们最终将把这些模型或表与列、索引、验证和其他模型的关系关联起来。目前,我们将在 Node.js 应用程序中使用 Sequelize 定义、选择(或查询)、插入、更新和删除这些表中的数据。如果您正在处理一个已经存在数据库的现有项目,那么“使用 Sequelize 操作和查询数据”这一部分将比从零开始的项目更有相关性。

我们首先将使用 Sequelize CLI 工具生成满足列的最小要求的模型。然后,我们将回顾 CLI 生成的代码,以便您更熟悉如何在不依赖 CLI 的情况下定义 Sequelize 模型。在项目的根目录下使用以下命令生成之前提到的模型:

sequelize model:generate --name Airplane --attributes planeModel:string,totalSeats:integer
sequelize model:generate --name Customer --attributes name:string,email:string
sequelize model:generate --name FlightSchedule --attributes originAirport:string,destinationAirport:string,departureTime:date
sequelize model:generate --name BoardingTicket --attributes seat:string

您可能已经注意到我们为模型名称使用了单数名词。Sequelize 会自动为我们将关联的表和模型复数化。您可以通过 Sequelize 配置设置来禁用此行为,这将在本章后面详细讨论。对于我们的 BoardingTickets 模型,我们将在下一章生成客户和航班计划的关联,但就目前而言,我们可以为表构建最基本的结构。

提示

Sequelize 为开发者提供了一些有用的实用函数。该框架使用一个名为 Sequelize 的库。

打开 models/flightschedule.js 文件,我们应该看到以下生成的代码:

'use strict';
const {
  Model
} = require('@sequelize/core');
module.exports = (sequelize, DataTypes) => {
  class FlightSchedule extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method
       automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  FlightSchedule.init({
    originAirport: DataTypes.STRING,
    destinationAirport: DataTypes.STRING,
    departureTime: DataTypes.DATE
  }, {
    sequelize,
    modelName: 'FlightSchedule',
  });
  return FlightSchedule;
};

代码片段中的 'use strict'; 行将告诉我们的 Node.js 运行时使用一组规则来执行 JavaScript 文件(models/flightschedule.js),以帮助减轻宽松模式严格模式将禁止开发者向未声明的变量赋值,使用由 ECMAScript 2015ES6)定义的保留关键字等。这种模式对于本书的内容完全是可选的;然而,如果您想了解更多,Mozilla 提供了一份关于严格模式和宽松模式之间差异的有用指南:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode/Transitioning_to_strict_mode

下一条指令是从 Sequelize 导入 Model 类,我们将在接下来的几个命令中使用它来初始化模型。随后的 module.exports 行是一个模式,Sequelize 模型加载器(models/index.js 文件)可以解释并调用该文件。第一个参数为我们提供了一个 Sequelize 实例,其中包含我们从 models/index.js 文件中设置的参数和配置设置。第二个参数 DataTypes 提供了一种更方便的方式来声明我们列的各种数据类型(例如,我们不需要输入 sequelize.DataTypes.STRING,我们可以省略 sequelize. 实例前缀,直接使用 DataTypes.STRING)。

接下来,我们定义 FlightSchedule 类并扩展 Sequelize 的 Model 类。在这里,我们可以定义我们的关联、钩子/事件、表信息等。CLI 将为我们生成一个名为 associations 的静态函数。目前,我们可以保持这个函数不变;我们将在本书的后续部分对其进行修改。

文件中的最后一条指令是调用 FlightSchedule 类的 init() 函数,这将设置内部规则和指令以帮助 Sequelize 导航模型定义。这也是 Sequelize 学习如何与数据库同步的地方,如下面的截图所示。如果你将 Sequelize 的选项设置为 sync: true,则会在每次运行时添加额外的 DROP TABLE IF EXISTS 指令,以便我们在每次运行时都能在一个干净的状态下测试我们的应用程序。将同步选项设置为 true 对于单元测试和早期原型开发很有用。不推荐在生产环境中将此选项设置为 true

图 2.1 – Sequelize 的自动同步

图 2.1 – Sequelize 的自动同步

注意

运行我们的 Node.js 应用程序并让 Sequelize 同步数据库对于初始实现阶段来说是可行的,但我们将介绍使用 Sequelize 的 CLI 和迁移来执行必要的 SQL 命令以同步数据库的方法。迁移提供了增量更改/更新,而 Sequelize 同步选项则更像是通用的解决方案。

init() 函数的第一个参数是我们定义模型属性(或列)的地方。这种模式的典型形式是一个对象,其键为列名,每个键的值可以是数据类型、一个字面字符串值,或者包含每个列高级选项的对象。从这个例子中,我们可以看到三个列(originAirportdestinationAirportdepartureTime),它们分别对应 stringstringdate 数据类型。

第二个参数允许我们明确地定义模型的实例类型设置。在这里,我们可以定义不同的表名,选择是否使我们的表名复数化,禁用元列(如 createdAtupdatedAt),等等。我们将在本章后面详细介绍这些选项。

如果你更喜欢不在你的项目中使用类,还有一种定义我们模型的方法。以下代码片段提供了一个使用 Sequelize 的 define() 函数的示例:

module.exports = (sequelize, DataTypes) => {
    return sequelize.define('FlightSchedule', {
        originAirport: DataTypes.STRING,
        destinationAirport: DataTypes.STRING,
        departureTime: DataTypes.DATE
    }, {
        sequelize,
    });
};

参数与 init() 几乎相同,只是第一个参数现在是模型名称。两种方式都是可接受的,并且从 Sequelize 的角度来看,一种方式并不比另一种方式有优势。本书将在其代码库(Model 类)中使用前者示例,但对于高级配置设置和添加关联,本书将展示两种风格,因为从人体工程学的角度来看,有一些基本差异。对于更喜欢使用 TypeScript 而不是 JavaScript 的程序员,Model 类方法可能为你提供更原生体验。

现在我们已经了解了如何在 Sequelize 中定义模型,我们可以回顾 Sequelize 提供的内置属性数据类型,以及简要说明以帮助指导你未来的模型设计。

探索各种 Sequelize 数据类型及其使用时机

如前所述,Sequelize 提供了各种数据类型,以帮助将模型的属性映射到相应的 数据库管理系统DBMS)列类型。以下是 Sequelize 提供的内容列表,以及简要说明。

STRING

STRING 数据类型指的是通常包含元信息的 VARCHAR 字段,以帮助优化数据库管理系统的查询计划。如果字符串的大小超过 255 字节,MySQL 会明确地在列的前缀标题中添加另一个字节。查询计划器可以使用这些信息来帮助减轻内存压力,或者对于具有固定分页长度的 VARCHAR,你将定义列为 DataTypes.STRING(100) 而不是 DataTypes.STRING

VARCHAR 列类型中,数据库管理系统不会以固定长度存储值(不需要填充)。如果你需要以与存储时完全相同的方式检索数据,可以使用 VARCHAR BINARY 列类型。这可以通过将列的数据类型声明为 DataTypes.STRING.BINARY 来实现。

尽管数据类型名称中包含“二进制”一词,但在存储电影、图片等时,通常建议使用 BLOB 类型而不是 VARCHAR BINARYVARCHAR BINARY 的二进制部分是在该列的二进制表示与 字符集charset)之间进行比较。

例如,假设我们在数据库中有以下行:AaBbVARCHAR 列类型将有一个内部映射来告诉数据库,Aa将排在Bb之前。在 VARCHAR BINARY 列中,AaBb 的二进制表示之和将是 0213,这将排序为以下顺序:ABabVARCHAR BINARY 列没有内部映射/字符集,因此数据库无法知道aA实际上是同一个字母。

对于大多数情况,我们可以在 MySQL 版本 5.0.2 以上将 VARCHAR BINARYBLOB 互换使用。这里有一些细微的差别,如下所示:

  • 对于 BLOB 索引必须指定索引前缀长度

  • BLOB 列类型不能有默认值

CHAR

CHAR 数据类型与 STRING 数据类型相似,不同之处在于它引用的是 CHAR 列类型。传统上,数据库管理系统会将 CHAR 列的长度限制为 255 个字符。VARCHAR 类型允许您超过指定的分页大小而不会出现错误或异常。CHAR 列可以用作最后的手段来验证您的数据,并确保它不超过指定的长度(例如,CHAR(20) 将数据限制为表中排序规则定义的 20 个字符)。CHAR 列类型会被填充到其固定长度,这有助于优化数据库管理系统——甚至您的应用程序——前提是预定的长度适合该场景的分页大小。

TEXT/TINYTEXT/MEDIUMTEXT/LONGTEXT

数据库设计者知道,有时我们的文本数据需要相当大的空间,或者需要与大于 65,535 字节(MySQL VARCHAR 限制)的行相关联。在这种情况下,我们会使用 TEXT 列类型。每个数据库管理系统都有其细微差别和限制;由于本书使用 MySQL,我们将简要介绍 MySQL 的 TEXT 限制,如下所示:

  • TINYTEXT: 255 字节

  • TEXT: 64 千字节 (KB)

  • MEDIUMTEXT: 16 兆字节 (MB)

  • LONGTEXT: 4 千兆字节 (GB)

DataTypes.TEXT 将默认为 TEXT 列类型,如果您想将列类型声明为 TINYTEXTMEDIUMTEXTLONGTEXT,则分别使用 DataTypes.TEXT('TINY')DataTypes.TEXT('MEDIUM')DataTypes.TEXT('LONG')。与 VARCHAR 类型不同,TEXT 列类型没有 BINARY 选项。对于存储序列化的二进制类型,您将使用 VARCHAR BINARYBLOB

CITEXT

CITEXT 代表 不区分大小写的文本,这是一个在比较操作之外保留数据大小写的列。此选项仅适用于 Postgres 和 SQLite 数据库。

NUMBER

不要与 Postgres 的NUMERIC类型混淆,NUMBER数据类型是根据其配置设置的一个抽象类型,而不仅仅是显式类型。除非您正在扩展/添加自己的数值数据类型,否则不应直接使用此抽象数据类型。如果您的数据库中使用了相同的精度和比例值,或者您的在线商店以不同的货币销售产品,则此抽象数据类型可以帮助组织您的代码。

以下代码片段提供了一个如何扩展您自己的数值数据类型的示例:

// INT(4)
var unsignedInteger = DataTypes.NUMBER({
    length: 4,
    zerofill: false,
    unsigned: true,
});
// FLOAT(5,4)
var specificFloat = DataTypes.NUMBER({
    length: 5,
    zerofill: false,
    unsigned: false,
    decimals: 4
});
// DECIMAL(6,4)
var specificPrecision = DataTypes.NUMBER({
    zerofill: false,
    unsigned: false,
    precision: 6,
    scale: 4
});

INTEGER/TINYINT/SMALLINT/MEDIUMINT/BIGINT

使用DataTypes.INTEGERDataTypes.SMALLINT等,我们可以将我们的属性与相应的列类型关联起来。您可以在 MySQL 中找到每个整数类型的最大和最小值参考:dev.mysql.com/doc/refman/5.7/en/integer-types.xhtml。要声明您的模型属性为无符号值,我们可以在数据类型上附加UNSIGNED选项,如下所示:

DataTypes.INTEGER(21).UNSIGNED

如果我们想要我们的属性无符号且零填充,我们可以链式调用数据类型选项,如下所示:

DataTypes.INTEGER(21).UNSIGNED.ZEROFILL

注意

根据您使用的数据库管理系统(DBMS),ZEROFILL选项可能不可用。如果您使用的是 Postgres 数据库,那么分配这些属性的顺序很重要(UNSIGNED必须在ZEROFILL之前声明)。在 MySQL 中,ZEROFILL选项也会自动隐含UNSIGNEDZEROFILL属性将只从美学角度(当您选择数据时)影响数据,而不会修改您的存储数据。

FLOAT/REAL

传统上,数据库管理系统(DBMS)会根据位精度来区分FLOATREAL列类型。FLOAT列通常以 32 位精度存储,而REAL列类型则以 64 位精度存储。REAL列类型是 64 位的,而FLOAT列是 32 位的。更令人困惑的是,MySQL 会将REAL视为与DOUBLE(也称为DOUBLE PRECISIONDECIMAL)列相同。

在内部,Sequelize 以相同的方式处理FLOATREALDOUBLE。对于FLOAT类型,会显式执行一个小浮点验证,但除此之外,Sequelize 将直接将列类型转换为数据库管理系统。就像整数数据类型一样,UNSIGNEDZEROFILL也可以定义在这些属性上,如下所示:

DataTypes.FLOAT.UNSIGNED.ZEROFILL

DECIMAL/DOUBLE

DECIMALDOUBLE数据类型允许我们使用传统的DECIMAL(P, S)格式定义列的精确长度和比例,其中P > SP变量是数字的精度,而S变量是数字的比例。精度决定了整个数字部分的长度,而比例定义了小数部分的长度。例如,DataTypes.DECIMAL(6, 4)将给我们一个精度为 6 和比例为 4 的十进制列。此列的一个示例值可以是38.3411

注意

你可以将 DataTypes.NUMERIC 作为 DataTypes.DECIMAL 的别名使用。

BOOLEAN

表达 falsetrue 的 1 有许多方式。有时,布尔值可能以字符串形式存储,例如 truefalsetf。Sequelize 会自动处理数值或位值,以及“true”或“false”字符串表达式作为 Node.js 的适当布尔值。如果值标记为“t”或“f”,则 Sequelize 将将原始值传递给程序员处理(作为一种避免过于自信的方式——此行为可能在将来发生变化)。布尔列可以用 DataTypes.BOOLEAN 定义。此数据类型没有参数或输入要处理。

DATE/DATEONLY/TIME

DATE 数据类型引用 MySQL、MariaDB 和 SQLite 的 DATETIME 列类型。对于 Postgres,DATE 数据类型将被转换为 TIMESTAMP WITH TIME ZONE

在 MySQL 中,你可以为 DATETIME 列定义最多六位小数的分数秒,如下所示:

DataTypes.DATE(6)

如果你只想保留日期或时间,你可以分别使用 DataTypes.DATEONLYDataTypes.TIME

关于不带时区的 Postgres 的快速说明

如果你使用的是带有 TIMESTAMP WITHOUT TIME ZONE 列类型的 Postgres,并且你知道数据的时间与运行应用程序的服务器不同,建议设置时区偏移量。这可以通过 pg Node.js 库实现,如下所示:

var types = require('pg').types

function setTimestampWithoutTimezoneOffset(val) {

    // '+0000' 是 UTC 偏移量,将其更改为所需时区

    return val === null ? null : new Date(stringValue + '+0000');

}

types.setTypeParser(types.builtins.TIMESTAMP, setTimestampWithoutTimezoneOffset);

有关在 Node.js 中为 Postgres 设置类型的更多信息,请参阅以下链接:github.com/brianc/node-pg-types

NOW

DataTypes.NOW 是 Sequelize 中的一个特殊类型。它不应用作列的类型,而应作为属性值,并且传统上设置为属性的 defaultValue 选项。如果我们想要一个 Receipt 模型来跟踪交易时间,它看起来会像这样:

Receipt.init({
    total: DataTypes.DECIMAL(10,2),
    tax: DataTypes.DECIMAL(10,2),
    dateOfPurchase: {
        type: DataTypes.DATE,
        defaultValue: DataTypes.NOW
    }
}, {
    sequelize,
    modelName: 'Receipt'
});

每当我们插入一个 Receipt 记录时,Sequelize 会自动将 dateOfPurchase 属性的值转换为 DBMS 的 NOW() 函数,使用 Sequelize 的 DataTypes.NOW 数据类型,并从属性的 defaultValue 选项中获取。如果我们最初为属性定义了一个值,那么 Sequelize 将使用该值。

HSTORE

HSTORE 仅适用于 Postgres。此数据类型用于映射键值类型,但通常被 JSONHSTORE 替换,但需要注意一个注意事项,即需要安装 pg-hstore Node.js 库。完整的安装命令如下所示:

npm install --save sequelize pg pg-hstore

在 Sequelize 中选择数据时,您的 where 子句将是一个对象,而不是整数、字符串等。一个例子如下所示:

MyModel.find({
  where: {
    myHstoreColumn: {
      someFieldKey: 'value',
    }
  }
});

JSON

JSON 数据类型适用于 SQLite、MariaDB、MySQL 和 Postgres。当使用 JSON 类型定义属性时,您可以查询类似于 HSTORE 类型查询的信息,除了您无法深度嵌套您的搜索子句。假设我们有一个以下 JSON 数据类型存储在列中:

{
    "someKey": {
        "deeply": {
            "nested": true
        }
    }
}

我们将按如下方式搜索嵌套值:

MyModel.find({
    where: {
        myJsonColumn: {
            someKey: { deeply: { nested: true } }
        }
    }
});

请注意,MySQL 和 MariaDB 引入了对 DataTypes.JSON 属性类型支持,将不会与您的数据库兼容。要解决这个问题,您可以定义具有获取器/设置器的模型,这些获取器/设置器将存储和检索 JSON 文档,如下所示:

sequelize.define('MyModel', {
    myJsonColumn: {
        type: DataTypes.TEXT,
        get: function () {
            return JSON.parse(this.getDataValue('value'));
        },
        set: function (val) {
            this.setDataValue('value',JSON.stringify(val));
        }
    }
});

注意

对于使用 MSSQL 2016 及以上版本的用户,请参阅 sequelize.org/master/manual/other-data-types.xhtml#mssql 作为处理此 DBMS 中 JSON 列类型的解决方案。

JSONB

JSONB 数据类型仅适用于 Postgres。如果您使用 JSON 列进行存储,建议使用 JSON 列类型;如果您在该列上使用比较运算符,建议使用 JSONB 列类型。

除了之前提到的查询 JSON 数据的方法之外,您还可以使用以下格式查询 JSONB 数据类型:

// String matching
MyModel.find({
  where: {
    "someKey.deeply.nested": {
      [Op.eq]: true
    }
  }
});
// Using the Op.contains operator
MyModel.find({
  where: {
    someKey: {
      [Op.contains]: {
        deeply: {
          nested: true
        }
      }
    }
  }
});

BLOB

包括 MySQL 在内的几个数据库提供了一系列 BLOB 属性类型,Postgres 总是将它们转换为 bytea(字节数组)列类型。这种数据类型适用于存储任何与二进制相关的数据,例如图像、文档或序列化数据。您可以在以下示例中看到它的使用:

DataTypes.BLOB // BLOB
DataTypes.BLOB('tiny') // TINYBLOB
DataTypes.BLOB('medium') // MEDIUMBLOB
DataTypes.BLOB('long') // LONGBLOB

这里是一个不同 BLOB 类型及其字节前缀长度和最大存储长度的表格,适用于 MySQL:

BLOB 类型 字节前缀长度 最大存储(以字节为单位)
TINYBLOB 1 字节 2⁸ - 1
BLOB 2 字节 2¹⁶ - 1
MEDIUMBLOB 3 字节 2²⁴-1
LONGBLOB 4 字节 2³² - 1

RANGE

RANGE 数据类型仅适用于 Postgres。支持的范围类型是 INTEGERBIGINTDATEDATEONLYDECIMAL。您可以定义具有范围类型的属性,如下所示:

var MyModel = sequelize.define('MyModel', {
    myRangeColumn: DataTypes.RANGE(DataTypes.INTEGER)
});

如此所示,我们可以为我们的模型创建范围,有几种方法可以做到这一点:

// inclusive boundaries are the default for Sequelize
   var inclusiveRange = [10, 20];
MyModel.create({ myRangeColumn: inclusiveRange });
// inclusion may be toggled with a parameter
   var range = [
    { value: 10, inclusive: false },
    { value: 20, inclusive: true }
];
MyModel.create({ myRangeColumn: range });

当查询范围列时,该属性的值将始终以对象表示法返回,带有 valueinclusive 键。

UUID/UUIDV1/UUIDV4

UUIDV1/UUIDV4 数据类型与 UUID 属性类型协同工作。我们可以声明一个具有默认 UUIDV4 值作为其 主键PK)的模型,如下所示:

sequelize.define('MyModel', {
    id: {
        type: DataTypes.UUID,
        defaultValue: DataTypes.UUIDV4,
        allowNull: false,
        primaryKey: true
    }
});

虚拟

VIRTUAL属性类型是一种特殊类型,它将在 Sequelize 中填充数据,但不会将数据填充到数据库中。VIRTUAL字段可用于组织代码、验证以及扩展 Sequelize 到任何需要嵌套类型(例如,GraphQL、Protocol BuffersProtobuf)等)的协议或框架,这将在第九章使用和创建适配器中介绍。

我们可以这样定义一个VIRTUAL属性:

sequelize.define('MyModel', {
    envelope: DataTypes.STRING,
    message: {
        type: DataTypes.VIRTUAL,
        set: function(val) {
            // the following line is optional
            // but required if you wish to use the
               validation associated with the attribute
            this.setDataValue('message', val);
            this.setDataValue('envelope', 
                               encryptTheMessage(val));
        },
        validate: {
            noDadJokes: function(val) {
                if (val === "knock knock") {
                    throw new Error("Who is there? Not this 
                                     message")
                }
            }
        }
    }
});

对于检索VIRTUAL属性,我们需要为DataTypes.VIRTUAL调用定义一个数据类型作为参数。如果我们想在我们的VIRTUAL属性中传递其他属性,我们将定义一个列表作为第二个参数。以下是一个示例:

sequelize.define('MyModel', {
    envelope: DataTypes.STRING,
    message: {
        type: DataTypes.VIRTUAL(DataTypes.STRING, ['en
        velope']),
        get: function() {
            return decryptTheMessage(this.get('envelope'));
        },
        set: function(val) {
            this.setDataValue('envelope', 
                               encryptTheMessage(val));
        }
    }
});

ENUM

Sequelize 有一个DataTypes.ENUM属性类型用于枚举列。目前,只有 Postgres 启用了此功能。对于其他数据库引擎的解决方案是定义一个自定义验证规则,该规则执行某种包含操作符。下一章将讨论我们模型的自定义验证。定义枚举属性有三种不同的方式,如下所示:

// Defining enums with function arguments
DataTypes.ENUM('a', 'b')
// Defining enums with an array argument
DataTypes.ENUM(['a', 'b'])
// Defining enums with an object argument
DataTypes.ENUM({
    values: ['a', 'b']
})

ARRAY

目前,只有 Postgres 支持ARRAY属性类型。此类型需要一个适用数据类型的参数。您可以在以下示例中查看:

DataTypes.ARRAY(DataTypes.STRING) // text[]
DataTypes.ARRAY(DataTypes.DECIMAL) // double precision[]

GEOMETRY

Sequelize 可以处理 MariaDB、MySQL 和 Postgres 的几何数据(只要启用了 PostGIS 扩展)。GeoJSON规范(tools.ietf.org/html/rfc7946)对于查询航空业务中的几何数据可能很有用。例如,我们可以标记机场的坐标和飞机的当前位置,以确定预计到达时间,而无需手动记住 Haversine 算法(一个确定球面上两点之间距离的公式)。以下代码片段中可以找到参考示例:

var MyModel = sequelize.define('MyModel', {
    point: DataTypes.GEOMETRY('POINT'),
    polygon: DataTypes.GEOMETRY('POLYGON')
});
var point = {
    type: 'Point',
    coordinates: [44.386815, -82.755759]
}
var polygon = { type: 'Polygon', coordinates: [
    [
        [100.0, 0.0], [101.0, 0.0], [101.0, 1.0],
        [100.0, 1.0], [100.0, 0.0]
    ]
]};
await MyModel.create({ point, polygon });

在前面的代码片段中,我们首先使用两个属性(pointpolygon)及其相应的几何数据类型(对于完整列表,您可以参考之前提到的请求评论RFC)手册)定义我们的模型。然后,我们使用一组定义的值创建我们的几何对象(一个点将接受两个坐标,一个多边形可以接受 N 个坐标)。最后一行将为相应的属性创建一个具有定义值的条目。

注意

GeoJSON 的处理方式取决于我们是否使用 Postgres 或 MariaDB/MySQL 方言。Postgres 方言将调用ST_GeomFromGeoJSON函数来解释 GeoJSON,而 MariaDB/MySQL 将使用GeomFromText函数。以下参考详细介绍了 MySQL 中的空间列:dev.mysql.com/doc/refman/5.7/en/spatial-types.xhtml

GEOGRAPHY

对于 MariaDB/MySQL,GEOGRAPHY属性类型将像GEOMETRY类型一样工作,但对于 Postgres,Sequelize 将使用 PostGIS 的地形数据类型。GEOGRAPHY属性类型遵循与GEOMETRY类型相同的 GeoJSON 语法。

注意

如果你正在寻找一组完整的实用函数,并查询多个坐标之间的复杂关系,那么建议使用GEOMETRY类型而不是GEOGRAPHY类型。如果你需要使用大地测量而不是笛卡尔测量,或者如果你在大面积上具有更简单的关系,那么GEOGRAPHY类型将更适合你。

CIDR/INET/MACADDR

这三种属性类型仅适用于 Postgres。这些类型各自执行一些内部验证。这些类型没有输入参数。以下是对这些数据类型的简要说明,并附有参考:

TSVECTOR

TSVECTOR数据类型用于通过 Postgres 的to_tsquery()函数中可用的高级运算符搜索文本列。这些运算符包括通配符匹配、否定匹配和布尔搜索。此属性类型仅适用于 Postgres,并且仅接受字符串变量作为值。在查询TSVECTOR属性时,Sequelize 不会隐式解释与关联函数(例如,to_tsvector)相关的属性类型。假设我们有以下模型定义:

var MyModel = sequelize.define('MyModel', {
    col: DataTypes.TSVECTOR
});
MyModel.create({
    col: 'The quick brown fox jumps over the lazy dog'
});

然后,我们想要查询col字段上的值,如下所示:

MyModel.find({
    where: { col: 'fox' }
});

生成的 SQL 将类似于以下内容:

SELECT * FROM MyModel WHERE col = 'fox';

Sequelize 将使用等于运算符解释此查询的where子句。为了利用TSVECTOR列类型,我们必须明确我们的意图,如下所示:

MyModel.find({
    where: {
        col: {
            [Op.match]: sequelize.fn('to_tsvector', 'fox')
        }
    }
});

这将把where子句的运算符从等于转换为匹配(@@)。sequelize.fn方法允许你显式调用你的 DBMS 中的函数。此过程生成的查询将如下所示:

SELECT * FROM MyModel WHERE col @@ to_tsvector('fox');

在学习如何定义我们的模型以及 Sequelize 中可用的数据类型之后,我们现在可以将我们的定义迁移到实际的数据库中。Sequelize 在其命令行工具中提供了一个迁移子命令,以便我们更容易地完成此操作。

将 Sequelize 中的图示更改和数据迁移到数据库

我们已经使用命令行工具生成的文件定义了我们的数据库模式,我们现在准备将这些定义迁移到我们的 DBMS。使用 Sequelize 的迁移可以帮助开发团队在多台机器上维护相同的模式结构。迁移可以提供历史参考,说明数据库是如何随时间变化的,这也可以帮助我们撤销某些更改,并将数据库的模式回滚到特定的时间。

迁移电路图更改

Sequelize CLI 提供了一种方便的方式来将更新传播到数据库。我们所有的电路图更改都将位于migrations目录中,我们所有的数据种子都将位于seeders目录中。本章将仅涵盖数据库结构的初始化。在随后的章节中,将有使用迁移工具添加和删除列(或索引)的示例。

定义数据库模型部分,我们使用了 Sequelize CLI 来生成我们的模型,这应该在migrations目录中创建了几个文件。每个文件都以时间戳、一个create和模型的名称为前缀。其中一个文件(例如20210914153156-create-airplane.js)的示例如下:

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Airplanes', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      planeModel: {
        type: Sequelize.STRING
      },
      totalSeats: {
        type: Sequelize.INTEGER
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Airplanes');
  }
};

当我们调用migrations子命令时,Sequelize 将使用up(…)方法的范围。down(…)方法保留用于当我们决定撤销或回滚迁移时。查询接口是一个数据库无关的适配器,它执行所有支持数据库引擎都可用的一般 SQL 命令。我们将在后面的章节中详细介绍查询接口。

你可能已经注意到 Sequelize 已经在我们的模型定义中添加了几个列。使用默认设置,Sequelize 将生成三个额外的列,如下所示:

  • id—一个设置为autoIncrementtrue的整数 PK。autoIncrement标志将创建一个序列值(例如,MySQL 等数据库将序列称为auto-increment列)。

  • createdAt—此字段将在行创建时生成一个时间戳。由于这是一个 Sequelize 识别的列,因此此列的默认值不需要我们明确声明DataTypes.NOW或任何等效值。当使用create()等适用方法时,Sequelize 将自动填充行的值。

  • updatedAt—与createdAt字段类似,但此值将自动从 Sequelize 更新,每次行更新时都会更新。

注意

我们可以通过配置设置来防止 Sequelize 自动创建这些属性。这些设置将在本章后面详细解释。

在我们项目的根目录中,运行以下命令以初始化迁移:

sequelize db:migrate

此命令将执行比遍历 migrations 目录更多的指令。Sequelize 首先会寻找一个名为 SequelizeMeta 的表,该表包含通过 migrations 子命令已处理的文件的相关元信息。找到或创建该表后,Sequelize 将按文件名的顺序遍历 migrations 表(时间戳是保持这种顺序的便捷方式),并跳过 SequelizeMeta 表中找到的任何文件。

注意

sequelize-clidb:migratedb:seed 命令将使用 NODE_ENV 环境变量来确定迁移/初始化数据的位置。作为替代,你可以使用 --url 选项指定要连接到的数据库,如下所示:sequelize db:migrate --url 'mysql://user:password@host.com/database'

如果我们在模型定义上犯了错误,迁移后,我们始终可以选择撤销更改,如下所示:

sequelize db:migrate:undo

这将撤销 Sequelize 执行的最后一个迁移。如果我们想撤销所有更改,还有一个子命令,如下所示:

sequelize db:migrate:undo:all

如果我们想撤销所有迁移直到某个点(这就是为什么在文件名前加时间戳很重要),我们可以运行以下命令:

sequelize db:migrate:undo:all --to XXXXXXXXXXXXXX-airlines.js

迁移完成后,我们应该运行以下命令:

$ mysql -uroot airline
mysql> show tables;

下面的屏幕截图应显示以下表:

图 2.2 – 显示项目表

图 2.2 – 显示项目表

初始化种子数据

现在我们已经建立了模式,我们可以通过在 seeders 目录内生成种子文件来开始用实际数据填充我们的数据库。种子数据传统上用于初始配置数据、静态信息等。好事成双——我们的商业伙伴刚刚通知我们,他们购买了五架飞机以帮助我们开始。我们可以为这些飞机创建种子数据,如下所示:

sequelize seed:generate --name initial-airplanes

这将在我们的项目 seeders 目录中生成一个文件,该文件包含将种子数据迁移到数据库所需的最基本信息。类似于我们的迁移文件,CLI 只公开了两种方法:up(…)down(…)

我们将用以下代码替换文件内容:

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.bulkInsert('Airplanes', [{
      planeModel: 'Airbus A220-100',
      totalSeats: 110,
      createdAt: new Date(),
      updatedAt: new Date()
    }, {
      planeModel: 'Airbus A220-300',
      totalSeats: 110,
      createdAt: new Date(),
      updatedAt: new Date()
    }, {
      planeModel: 'Airbus A 318',
      totalSeats: 115,
      createdAt: new Date(),
      updatedAt: new Date()
    }, {
      planeModel: 'Boeing 707-100',
      totalSeats: 100,
      createdAt: new Date(),
      updatedAt: new Date()
    }, {
      planeModel: 'Boeing 737-100',
      totalSeats: 85,
      createdAt: new Date(),
      updatedAt: new Date()
    }], {});
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.bulkDelete('Airplanes', null, {});
  }
};

注意

与 Sequelize 的 create() 函数不同,查询接口的 bulkInsert() 方法不会自动填充 createdAtupdatedAt 列。如果你从种子文件中省略这些列,数据库将返回错误,因为这些列没有默认值。

现在,我们可以通过以下命令处理我们的种子数据:

sequelize db:seed:all

我们可以通过在数据库中输入以下 SQL 命令来确认更改:

SELECT * FROM airplanes;

我们随后得到以下结果:

图 2.3 – 查询飞机列表

图 2.3 – 查询飞机列表

回滚种子数据与 migrations 子命令类似,如下所示:

sequelize db:seed:undo
sequelize db:seed:undo:all
sequelize db:seed:undo --seed <the name of your seed file>

提示

Sequelize 在内部使用另一个名为 Umzug 的项目进行迁移。完整的参考和更多关于如何调整迁移周期的示例可以在项目的 GitHub 仓库中找到:github.com/sequelize/umzug

在将种子数据插入数据库后,我们现在可以使用 Sequelize 查询或操作这些数据。以下部分将提供一个关于如何将 Sequelize 集成到 Express 应用程序的简要介绍,并遵循 Sequelize 的参考风格。这将帮助您了解我们将在后续章节中如何应用 Sequelize 到我们的航空公司项目中,并为您提供足够的知识,以便您能够舒适地调整自己的设置。

使用 Sequelize 操作和查询数据

在初始化我们的数据库结构和数据后,我们应该能够从我们的仪表板中查看、修改和删除飞机。目前,我们将为我们的管理任务创建非常简单和基础的实现,但由于我们是 Avalon Airlines 唯一的技术员工,这不会成为问题。随着我们继续创建项目,我们将修改我们的应用程序,使其更加健壮,并考虑安全措施。

读取数据

app.get('/', …) 块替换为以下代码(在 index.js 中):

app.get('/', async function (req, res) {
    const airplanes = await models.Airplane.findAll();
    res.send("<pre>" + JSON.stringify(airplanes, undefined, 
              4) + "</pre>");
});

然后,保存文件,并使用以下命令运行我们的应用程序:

npm run start

现在,我们可以访问我们的网站 http://localhost:3000,应该会看到与这里显示的类似的结果:

图 2.4 – 列出我们的飞机

图 2.4 – 列出我们的飞机

现在,我们将创建另一个路由,该路由将返回特定飞机的结果。如果找不到飞机,则应发送 未找到 404)。在根 app.get('/', …) 块下方添加以下路由):

app.get('/airplanes/:id', async function (req, res) {
    var airplane = await models.Airplane.findByPk
                   (req.params.id);
    if (!airplane) {
        return res.sendStatus(404);
    }
    res.send("<pre>" + JSON.stringify(airplane, undefined, 
              4) + "</pre>");
});

findByPk 方法将尝试从模型的 PK 属性(默认情况下,这将是由 Sequelize 生成的 id 列)中查找记录。当找到记录时(例如,localhost:3000/airplanes/1),应用程序将返回记录给我们,但如果我们将 id 参数从 1 更改为 10 (http://localhost:3000/airplanes/10),我们应该收到一个 未找到 的响应。

下面是一个包含 Sequelize 数据检索相关函数简要说明的列表:

  • findAll—当您想在查询中使用 where 子句并检索多行时使用此函数。

  • findOne—与 findAll 函数类似,但此函数将返回单行记录。

  • findByPk—一个返回使用模型定义的 PK 的单行记录的函数。

  • findOrCreate—此函数将返回数据库中找到或实例化的单个行实例。Sequelize 将组合 wheredefaults 键中定义的属性。

复杂查询

有时候,你可能需要比简单的 where 子句和 AND 运算符更多的东西。Sequelize 内置了几个运算符来帮助编写具有更复杂 where 子句的查询。这些运算符的完整列表如下所示:

  • and/or—逻辑 AND 和逻辑 OR。这些值包含一个 where 子句对象的数组。

  • eq/ne—等于 (=) 或不等于 (!=)。

  • gte/gt—大于等于 (>=) 和大于 (>)。

  • lte/lt—小于等于 (<=) 和小于 (<)。

  • is/notIS NULLIS NOT TRUE,分别。

  • in/notIn—任何具有值的数组的 INNOT IN 运算符。

  • any/all/valuesANY(仅适用于 Postgres)、ALLVALUES 运算符,分别。

  • col—将列字符串值转换为数据库/方言指定的 标识符IDs)。

  • placeholder—Sequelize 使用的内部运算符。

  • join—由 Sequelize 内部使用。

  • match—用于文本搜索的匹配运算符(仅适用于 Postgres)。

  • like/notLikeLIKENOT LIKE,分别。

  • iLike/notILikeLIKENOT LIKE 的不区分大小写的版本(仅适用于 Postgres)。

  • startsWith/endsWithLIKE '%...'LIKE '...%' 表达式的简写。

  • substringLIKE '%...%' 的简写表达式。

  • regexp/notRegexp—仅适用于 MySQL 和 Postgres 的 REGEXPNOT REGEXP

  • between/notBetweenBETWEEN x AND yNOT BETWEEN x AND y

  • overlap—仅适用于 Postgres 的范围重叠运算符 (&&)。

  • contains/contained—分别对应 @><@ 的 Postgres 专用范围运算符。

  • Adjacent—仅适用于 Postgres 的相邻查询范围运算符 (-|-)。

  • strictLeft/strictRight—Postgres 范围(<<>>)的严格运算符。

  • noExtendRight/noExtendLeft—Postgres 的无扩展范围运算符 (&<&>)。

查询复杂的 where 子句可能看起来像这样:

const { Op } = require("sequelize");
MyModel.findAll({
    where: {
        [Op.or]: [
            { status: 'active' },
            sequelize.where(sequelize.fn('lower', se
            quelize.col('name')), {
                [Op.eq]: 'bob'
            },
            {
                [Op.and]: {
                    age: {
                        [Op.gte]: 40
                    },
                    name: {
                        [Op.like]: 'mary%'
                    }
                }
            }
        }]
    }
});

这将产生以下查询:

SELECT
    ...
FROM "MyModel"
WHERE (
    status = 'active'
    OR
    lower(name) = 'bob'
    OR (
        age >= 40
        AND
        name LIKE 'mary%'
    )
)

删除数据

对于删除一个实例(单个记录),我们可以调用一个 destroy() 函数,如下所示:

var record = MyModel.findOne({ where: { /* ... */ } });
await record.destroy();

注意

如果在你的模型定义中没有标记为 PK 的属性,那么 Sequelize 可能无法删除正确的记录。实例的 destroy() 方法会使用一个尝试匹配实例所有属性的 where 子句被调用。这可能导致意外的删除。

要一次性删除多行,执行以下代码:

MyModel.destroy({ where: { name: 'Bob' }});

你可以通过传递配置选项到 destroy() 方法来删除表中所有数据,如下所示:

await MyModel.destroy({ truncate: true });
// or
await MyModel.truncate();

更新和保存数据

Sequelize 提供了几种更新属性/数据的方法,具体取决于你从哪里更新。如果你希望更新多行,我们可以使用模型的 update() 函数,如下所示:

await MyModel.update({ name: "John" }, {
  where: { name: null }
});

此查询将更新所有记录的名称为 John,其中当前值为 NULL。对于更新特定实例,我们会更改属性值,然后调用 save() 函数,如下所示:

var record = MyModel.findOne();
record.name = "John";
await record.save();

如果你正在更改记录的属性,并且你的工作流程需要你将记录的数据重置回原始值(而不接触数据库),你可以使用reload()方法,如下所示:

var record = MyModel.findOne({ where: { name: 'John' } });
record.name = "Bob";
record.reload();
// the record.name attribute's value is now back to John

创建数据

要创建单行,Sequelize 的代码将类似于以下内容:

await MyModel.create({ firstName: 'Bob' }, { ... });

create()的第二个参数接受以下选项:

  • raw—如果此布尔值设置为true,则 Sequelize 将忽略模型定义中的虚拟设置器属性。当你想跳过通过设置器函数转换数据并直接使用查询提供的原始值时,这很有用。

  • isNewRecord—一个布尔值,可以启用(如果设置为true)Sequelize 应用默认值、更新时间戳列等行为。此方法的默认值为true

  • include—包含 Sequelize 的包含选项的数组。本书将在后面的章节中提供示例和更多细节。

  • fields—包含将过滤要更新、验证和保存的属性名称的字符串数组。

  • silent—如果此值设置为true,则 Sequelize 不会更新updatedAt时间戳列。

  • validate—一个布尔值,用于切换是否执行验证。

  • hooks—一个布尔值,用于启用/禁用createupdatevalidate生命周期事件之前/之后运行。

  • logging—一个函数,将传递查询的语句。

  • benchmark—记录执行查询时间(以毫秒为单位),并将作为logging函数的第二个参数传递。

  • transaction—你可以传递一个事务 Sequelize 实例作为此选项。

  • searchPath—仅 Postgres 选项,用于定义查询时使用哪个search_path

  • returning—仅 Postgres 选项,用于创建新记录时选择返回哪些字段。布尔值true将返回所有字段,但字符串数组将过滤要返回的列。

批量插入数据与使用 Sequelize 创建单行非常相似。以下代码片段展示了这一示例:

await MyModel.bulkCreate([
    { firstName: 'Bob' },
    { firstName: 'William' }
], {...});

第一个参数是值数组,第二个参数是配置选项。这些选项与create()方法相同:fieldsvalidatehookstransactionloggingbenchmarkreturningsearchPath。此外,bulkCreate()方法还提供了以下选项:

  • individualHooks—在为每个记录执行创建生命周期事件之前/之后运行。这不会影响批量之前/之后生命周期事件。

  • ignoreDuplicates—通过在表上定义的任何约束键忽略重复的行。此功能不支持 MSSQL 或低于 9.5 版本的 Postgres。

  • k—如果存在重复键条目,则更新字段的数组(仅适用于 MySQL/MariaDB、SQLite 3.24.0+和 Postgres 9.5+)。

排序和分组

当筛选你的数据时,你可以按如下方式对列进行排序(或分组):

MyModel.findAll({
    where: { name: 'Bob' },
    order: [
        ['name', 'DESC']
    ]
});

对于分组,根据你使用的数据库,你可能与其他数据库引擎(例如,要求你只选择聚合函数和分组列)得到不同的结果。请咨询你数据库的文档,以了解分组所需的特定细微差别和规则。以下是一个简单的 GROUP BY 语句示例:

MyModel.findAll({ group: 'name' });

警告

Sequelize 将将组的输入视为字面值。如果你是按用户生成的内容分组,强烈建议你转义你的值以避免 SQL 注入(en.wikipedia.org/wiki/SQL_injection)。你可以使用 sequelize.escape('...'); 方法来转义值。

限制和分页

我们可以简单地使用 offsetlimit 键值来为我们的查找方法,如下所示:

MyModel.findAll({ offset: 5, limit: 10 });

这将选择 MyModel 表,限制为 10 行,偏移量为 5。

注意

limit 属性会告诉数据库只检索指定数量的行(在 MSSQL 中,这将是 SELECT TOP NFETCH NEXT N ROWS)。offset 属性会在检索结果之前跳过 N 行。对于 MSSQL 2008(及更早版本)的用户,Sequelize 将通过嵌套查询来模拟偏移行为,以实现兼容性和完整性。

现在我们已经完成了对 Sequelize 查询和操作数据方法的引用,我们现在可以讨论在定义模型时更高级的选项。这些选项可以改变 Sequelize 内部转换数据的方式,过滤查询数据,并调整命名约定,使你能够更好地适应 Sequelize 的行为以满足你公司/项目的需求。

定义模型的高级 Sequelize 选项

在 Sequelize 中定义模型时,init()define() 方法的最后一个输入参数为我们提供了一种微调项目需求和 Sequelize 行为的方式。这些参数选项对于需要在我们不遵循 Sequelize 命名约定(例如,列名为 PersonIsMIA 而不是 Sequelize 的 "PersonIsMia" 约定)的现有环境中构建 Sequelize 的情况非常有用。

sequelize

一个(或新的)Sequelize 实例,用于与模型关联。如果未提供此字段,Sequelize 将返回错误(除了使用 sequelize.define 方法时)。这对于跨数据中心或数据库查询非常有用。

modelName

明确使用字符串定义模型的名称。这将是 Sequelize 的 define() 方法的第一个参数。如果你正在使用 ES6 类定义,此值的默认值将是类名。

defaultScope/scopes

一个对象,用于设置模型的默认作用域和为模型设置适用的作用域。作用域对于代码组织或强制执行基本访问控制列表作为默认行为非常有用。我们将在后面的章节中详细介绍作用域。

omitNull

将此布尔值设置为 true 将告诉 Sequelize 在保存记录时省略任何具有 null 值的列。

timestamps

此选项允许我们控制 Sequelize 为模型添加 createdAtupdatedAt 时间戳列的行为。此设置的默认值为 true(Sequelize 将创建时间戳列)。

注意

你可以通过在你的模型中显式定义它们来始终覆盖 createdAtupdatedAt 属性的默认设置。Sequelize 将知道使用这些属性来处理时间戳相关的列。

paranoid

当此布尔选项设置为 true 时,将防止 Sequelize 删除数据(默认行为)并添加 deletedAt 时间戳列。为了使 paranoid 选项生效,必须将 timestamps 选项设置为 trueparanoid 的默认值为 false

以下查询将执行“软删除”:

await Post.destroy({
  where: {
    id: 1
  }
});

此查询将更新 ID 为 1 的 Post 记录并更新 deletedAt 列。如果我们想从数据库中删除记录(而不是更新它),我们将使用 force 参数,如下面的代码片段所示:

await Post.destroy({
  where: {
    id: 1
  },
  force: true
});

这将执行数据库上的 delete 查询而不是 update 查询。

createdAt/updatedAt/deletedAt

此选项将分别重命名 createdAtupdatedAtdeletedAt 属性。如果你提供驼峰式命名的值,并且设置了下划线选项为 true,Sequelize 将自动转换列的命名格式。将值设置为 false 而不是字符串将告诉 Sequelize 禁用该列的默认行为。

underscored

默认情况下,Sequelize 将使用驼峰式命名创建列(例如,updatedAtfirstName 等)。如果你更喜欢下划线或蛇形命名(例如,updated_atfirst_name 等),则应将此值设置为 true

freezeTableName

如前所述,Sequelize 默认会将从模型名称派生的表名进行复数化。将此值设置为 true 将防止 Sequelize 转换表名。

tableName

明确定义 Sequelize 在创建 SQL 查询时使用的表名。此选项的典型用例是将 Sequelize 集成到现有的数据库/模式中,或者当复数设置不正确时。

name

一个对象,包含两个可用的选项来定义在将此模型与其他模型关联时使用的单数和复数名称。在后面的章节中介绍模型关联和关系时,我们将提供更清晰的解释和示例,但你可以在此处查看这两个选项的概述:

  • singular—在引用模型的单个实例时使用的名称(默认为 Sequelize.Utils.singularize(modelName)

  • pluralize—在引用模型的多实例时使用的名称(默认为 Sequelize.Utils.pluralize(modelName)

schema

定义模型的模式(在 Postgres 中这将被称为 search_path)。并非所有数据库都支持模式,有些会将模式完全视为数据库。

engine

仅适用于 MySQL,这是您定义表引擎类型的地方(通常是 InnoDBMyISAM)。默认为 InnoDB

charset

指定表的字符集。当您的表内容可以确定性地定义为一组字符,这有助于减少数据库大小时很有用(如果您不需要通用编码,并且只需要拉丁字符,则应使用拉丁派生的字符集)。

collation

指定表的校对(字符的排序和排序规则)。

comment

为表添加注释(如果适用)。

initialAutoIncrement

为适用方言设置初始 AUTO_INCREMENT 值(MySQL 和 MariaDB)。

钩子

一个对象,其键映射到钩子(或生命周期事件)。值可以是函数或函数数组。我们将在下一章详细介绍钩子。

validate

一个对象,用于定义模型验证。我们将在下一章详细介绍验证。

indexes

定义表索引定义的对象数组。这些索引在调用 sync() 或使用迁移工具时创建。每个对象都有以下选项:

  • name—索引的名称(Sequelize 将默认使用模型的名称和通过下划线连接的相关字段)。

  • type—用于定义索引类型的字符串值(仅限 MySQL/MariaDB)。通常,您会在这里定义 FULLTEXTSPATIAL 索引(UNIQUE 也可以,但有一个与方言无关的选项用于创建唯一索引)。

  • unique—将此值设置为 true 将创建一个唯一索引。

  • using—索引 SQL 语句的 USING 子句值。一些示例包括 BTREE(通常,数据库管理系统将使用此索引类型作为默认值),HASH(仅限 MySQL/MariaDB/Postgres),以及 GIST/GIN(仅限 Postgres)。

  • operator—定义用于此索引的运算符(主要用于 Postgres,但也可以用于其他方言)。

  • concurrently—将此设置为 true 提供了一种创建索引而不写入锁的方法(仅限 Postgres)。

  • fields—为模型定义的索引字段数组。请参阅下一节的 索引字段

索引字段

每个索引定义的字段值可以是以下之一:

  • 表示索引名称的字符串

  • Sequelize 文字对象函数(例如,sequelize.fn()

  • 一个具有以下键的对象:

    • attribute—用于索引的列的字符串值

    • length—定义前缀索引的长度(如果适用)

    • order—确定排序是升序还是降序

    • collate—定义列的校对规则

这里提供了一个快速示例,说明如何在定义 Sequelize 模型时使用一些这些高级选项:

class User extends Model { }
User.init({
    name: DataTypes.STRING,
}, {
    sequelize,
    modelName: 'User',
    omitNull: true,
    // renames deletedAt to removedAt
    deletedAt: 'removedAt',
    // start with ID 1000
    initialAutoIncrement: 1000,
    validate: {
        isNotBob() {
            if (this.name === 'bob') {
                throw new Error("Bob is not allowed to be a 
                                 user.");
            }
        }
    },
    indexes: [
        { unique: true, fields: ['name'] }
    ],
});

摘要

在本章中,我们详细概述了使用 Sequelize 定义模型的各种参数和配置设置。我们还学习了如何使用 Sequelize CLI 自动生成模型定义(和数据)文件,以及如何将这些定义迁移到数据库。本章还涵盖了 Sequelize 提供的各种属性类型,以及从 Sequelize 查询或更新数据到数据库的方法。

在下一章中,我们将讨论模型验证、建立外键关系以及如何约束数据以满足项目需求。

第二部分 – 验证、自定义和关联您的数据

在本部分,您将更深入地了解 Sequelize 的模型属性,并添加验证、自定义列类型和相关关联模型。您将探索钩子、JSON 和 Blob 类型,以及事务。

本部分包括以下章节:

  • 第三章, 验证模型

  • 第四章, 关联模型

  • 第五章, 将钩子和生命周期事件添加到您的模型中

  • 第六章, 使用 Sequelize 实现事务

  • 第七章, 处理自定义、JSON 和 Blob 数据类型

第三章:验证模型

在数据库中保持一致性和完整性非常重要。数据库通常使用某种形式的约束规定来确保一致性。通常,这些约束包括检查值范围,例如最小字符串长度、唯一性或存在性。数据库的完整性涉及管理共生记录之间的关联和关系。这包括级联更新和删除引用记录(例如,当引用记录被删除时,将关联的标识列设置为NULL)。一致性和完整性不是相互排斥的,但这两个模式有助于确保组织。

注意

一致性这个术语指的是确保只有有效数据会被写入和从数据库中读取(尤其是在并发访问数据的情况下)。完整性指的是在插入或读取之前符合一组规则、约束或触发器的数据。

虽然大多数数据库引擎都处理一致性和完整性,但在一致性方面存在一些限制。如果您想要对数据库范围之外的第三方源执行验证,您可能需要为数据库构建(或安装)一个扩展来添加支持,或者使用一个中央代码库来帮助管理这些验证。

Sequelize 为各种数据类型提供了内置验证,以帮助提高项目的易用性。某些验证需要手动配置,例如检查文本值是否匹配电子邮件模式,或者某些验证需要手动输入,例如数值(或日期)范围。

在 Sequelize 中,可以使用两种方法执行验证:

  • 我们可以在涉及多个属性的整个记录上执行验证

  • 我们可以为每个特定的属性调用验证

在本章中,我们将探讨 Sequelize 如何执行验证,以在数据库中保持一致性和完整性。本章将涵盖以下主题:

  • 使用验证作为约束

  • 创建自定义验证方法

  • 在执行异步操作时执行验证

  • 处理验证错误

注意

Sequelize 将内部使用一个名为validator.js的验证库。本章将介绍 Sequelize 明确扩展的验证。有关可以使用完整验证列表,您可以参考validator.js存储库github.com/validatorjs/validator.js

技术要求

您可以在以下位置找到本章的代码文件:github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch3

使用验证作为约束

有些验证 Sequelize 既用作验证又用作约束。这些参数可以在属性的选项中作为validate参数的兄弟进行配置。约束由数据库定义并受保护,而验证将由 Sequelize 和 Node.js 运行时独家处理。以下是 Sequelize 提供的约束列表。

allowNull

allowNull选项将确定是否将NOT NULL应用于数据库列的定义。默认值是true,这将允许列具有null值。在使用带有allowNull约束的验证时,有几个注意事项需要记住:

  • 如果将allowNull参数设置为false且属性的值为null,则自定义验证将不会运行。相反,将返回一个ValidationError,而无需向数据库发出请求。

  • 如果allowNull参数设置为true且属性的值为null,则内置验证器将不会被调用,但自定义验证器仍然会执行。

以下是对各种验证状态的解释,这些状态将根据allowNull参数的设置导致 Sequelize 相应地表现:

User.init({
  age: {
    type: Sequelize.INTEGER,
    allowNull: true,
    // if the age value is null then this will be ignored
    validate: {
      min: 1
    }
  },
  name: {
    type: DataTypes.STRING,
    allowNull: true,
    validate: {
      // even if the name's value is null, the
         customValidator will still be invoked
      customValidator(value) {
        if (value === null && (this.age === null || 
            this.age < 18)) {
          throw new Error("A name is required unless the 
                           user is under 18 years old");
        }
      }
    }
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    validate: {
      // if the email value is null then this will not be 
         invoked
      customValidator(value) {
        // ...
      }
    }
  }
}, { sequelize });

在前面的示例中,在第一列age中,Sequelize 将执行验证检查以确保数值不低于一。下一列name将调用一个自定义验证函数,该函数检查属性的新值是否为null,如果是,则检查用户的年龄。最后一列email演示了如果将allowNull标志设置为false且值本身为null,Sequelize 将不会调用验证。

您可以通过调整属性验证配置中的notNull参数来自定义NOT NULL错误,如下所示:

User.init({
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    validate: {
      notNull: {
        msg: 'Please enter your e-mail address'
      }
    }
  }
}, { sequelize });

否则,Sequelize 将返回数据库发送的错误消息。

unique

将此参数设置为true将在数据库中为适用列构建一个唯一约束,如果您正在使用 Sequelize 的sync选项。如果存在唯一约束违规,Sequelize 将返回一个类型为SequelizeUniqueConstraintError的错误。以下是如何使用unique的快速示例(您还可以为唯一性允许可空值):

MyModel.init({
  email: {
    type: DataTypes.STRING,
    // by default allowNull is true
    allowNull: false,
    unique: true
  }
}, { sequelize });

作为一般规则,您应该在适用的情况下使用约束而不是验证,因为此选项也将应用于数据库。在约束不适用的场合,我们可以使用 Sequelize 的内置验证之一。

注意

当在unique属性上将allowNull设置为true时,数据库将允许在该属性上具有相同NULL值的多个记录。这是数据库管理系统方面的故意行为,可以通过显式地向unique索引添加约束来缓解,如下所示:

CREATE UNIQUE INDEX idx_tbl_uniq ON tbl (a, (b IS NULL)) WHERE b IS NULL

内置验证

这些验证是在 Node.js 运行时内执行的,而不是从数据库中执行。Sequelize 将通过其自己的验证器集扩展 validator.js 的功能。

is (regex), not (notRegex), 和 equals

isnot 验证参数可以是字面量正则表达式或一个数组,其中第一个条目是正则表达式的字符串字面量,第二个条目是正则表达式标志。equals 参数是一个字符串值,用于执行严格的匹配检查。

以下是一个如何使用这三个验证器的模型示例:

MyModel.init({
  foo: {
    type: DataTypes.STRING,
    validate: {
      is: /^[a-z]+$/i
      // can also be written as:
      // is: ['^[a-z]+$', 'i']
    }
  },
  bar: {
    type: DataTypes.STRING,
    validate: {
      not: /^[a-z]+$/i
      // can also be written as:
      // not: ['^[a-z]+$', 'i']
    } 
  },
  foobar: {
    type: DataTypes.STRING,
    validate: {
      // ensure 'foobar' is always equaled to 'static
         value'
      equals: 'static value'
    }
  }
}, { sequelize });

isEmail

此验证将确保属性值符合 RFC 2822 规则,该规则可在 datatracker.ietf.org/doc/html/rfc2822 查阅。

isUrl

这将验证属性值是否是一个实际的 URL,具有各种协议、主机名(IP 和 FQDN)以及最大长度。

isIP, isIPv4, 或 isIPv6

这验证属性值是否与 IP 值的外观相匹配。isIP 验证接受 v4 和 v6 格式。

isAlphanumeric, isNumeric, isInt, isFloat, 和 isDecimal

所有用于验证的输入都作为字面量字符串发送到 validator.js 库。这些验证器将确保输入可以解析为相应的验证。

max 或 min

这些仅适用于数值属性。它们分别为属性验证添加最大或最小数值值。

isLowercase 或 isUppercase

这些检查属性值中的每个字母是否都使用了正确的格式。

isNull, notNull, 或 notEmpty

这验证值是否为 null 或不是。notEmpty 验证器将验证值中是否有任何空格、制表符或换行符。

contains, notContains, isIn, 或 notIn

这些相关验证器将对值执行子字符串检查。相关验证器接受数组参数内的任何值。例如,请参见以下内容:

MyModel.init({
  foo: {
    type: DataTypes.STRING,
    validate: {
      isIn: [['red', 'yellow', 'green']]
    }
  },
  bar: {
    type: DataTypes.STRING,
    validate: {
      contains: 'foo'
    } 
  }
}, { sequelize });

len

len 验证器接受一个包含两个参数的数组作为其输入。这些参数用于检查值长度与最小和最大数值分别对应。要创建一个具有最小长度为 1 和最大长度为 40 的值长度验证,它看起来如下所示:

MyModel.init({
  foo: {
    type: DataTypes.STRING,
    validate: {
      len: [1, 40]
    }
  }
}, { sequelize });

isUUID

此验证器可以检查一个值是否符合唯一标识符的要求。您可以指定版本(3、4 或 5)作为输入参数,或者使用字面量字符串值 all 以接受任何 UUID 版本。

isDate, isAfter, 或 isBefore

isDate 验证器将确定一个值是否类似于日期。isAfterisBefore 验证器将执行与您尝试验证的日期的时间比较。默认输入比较是 new Date()。以下是一个快速示例:

MyModel.init({
  expiration: {
    type: DataTypes.DATE,
    validate: {
      isAfter: '2060-01-01'
      // for "now"
      // isAfter: true
    }
  }
}, { sequelize });

备注

isBeforeisAfter的输入是一个符合任何适用日期的字符串,该日期可以被 JavaScript 解析。有关兼容格式的示例,您可以参考此链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse

现在我们已经通过几个示例了解了如何将验证应用于 Sequelize 模型的属性,我们可以更新 Avalon Airlines 项目中的几个文件。

在我们的项目中应用验证

在以下示例中,我们将为我们的 Airplane 模型的planeModeltotalSeats属性添加验证。我们可以从打开models/airplane.js文件并添加以下验证开始:

  • 对于planeModel属性,添加一个notEmpty验证,因为所有飞机型号都需要一个非null且非空字符串的值。

  • totalSeats属性上,添加一个最小验证值为1作为参数值,因为每架飞机都必须至少有一个座位可供顾客使用。

更新后的文件应类似于以下内容:

const { Model } = require('@sequelize/core');
module.exports = (sequelize, DataTypes) => {
  class Airplane extends Model {
    static associate(models) {
    }
  };
  Airplane.init({
    planeModel: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: {
          msg: 'Plane types should not be empty'
        }
      }
    },
    totalSeats: {
      type: DataTypes.INTEGER,
      validate: {
        min: {
          args: 1,
          msg: 'A plane must have at least one seat'
        }
      }
    }
  }, {
    sequelize,
    modelName: 'Airplane',
  });
  return Airplane;
};

接下来,我们将想要修改models/boardingticket.js文件,并添加以下notEmpty验证器:

const { Model } = require('@sequelize/core');
module.exports = (sequelize, DataTypes) => {
  class BoardingTicket extends Model {
    static associate(models) {
    }
  };
  BoardingTicket.init({
    seat: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: {
          msg: 'Please enter in a valid seating arrangement'
        }
      }
    }
  }, {
    sequelize,
    modelName: 'BoardingTicket',
  });
  return BoardingTicket;
};

在本节中最后要编辑的文件将是models/customer.js文件。名称属性将需要一个notEmpty验证器,而电子邮件属性将需要一个isEmail验证器,如下所示:

const { Model } = require('@sequelize/core');
module.exports = (sequelize, DataTypes) => {
  class Customer extends Model {
    static associate(models) {
    }
  };
  Customer.init({
    name: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: true,
        msg: 'A name is required for the customer'
      }
    },
    email: {
      type: DataTypes.STRING,
      validate: {
        isEmail: true,
        msg: 'Invalid email format for the customer'
      }
    }
  }, {
    sequelize,
    modelName: 'Customer',
  });
  return Customer;
};

在经过内置验证列表之后,我们现在可以学习如何构建自己的验证,以及如何在整个模型中使用自定义验证。

创建自定义验证方法

Sequelize 允许我们通过向属性或模型选项的validate参数(Model.init()函数的第二个输入参数)中添加一个函数来创建自己的验证。

如果我们想要创建自己的验证来限制用户不能使用password作为密码,我们会编写一个类似以下的解决方案:

MyModel.init({
  password: {
    type: DataTypes.STRING,
    validate: {
      notLiteralPassword(value) {
        if (value === 'password') {
          throw new Error("Your password cannot be 
                           'password'");
        }
      }
    }
  }
}, { sequelize });

尽管你可以在自定义属性验证器中的其他属性上检查值,但在涉及多个属性进行验证时,声明一个模型自定义验证器被认为是良好的实践,我们将在稍后演示这一点。

我们还有一个模型文件需要添加验证。models/flightschedule.js文件需要验证出发机场不能与目的地机场相同。首先,我们需要导入 Sequelize 并添加一个可用机场列表:

const { Model } = require('@sequelize/core');
const availableAirports = [
  'MIA',
  'JFK',
  'LAX'
];

接下来,添加模块导出和模型类扩展行:

module.exports = (sequelize, DataTypes) => {
  class FlightSchedule extends Model {
    static associate(models) {
    }
  };

创建自定义属性验证器

然后,我们可以使用具有相关验证的属性定义初始化我们的模型。我们希望向originAirportdestinationAirport属性添加isIn验证器:

  FlightSchedule.init({
    originAirport: {
      type: DataTypes.STRING,
  // examples of custom attribute validators
      validate: {
        isIn: {
          args: [availableAirports],
          msg: 'Invalid origin airport'
        }
      }
    },
    destinationAirport: {
      type: DataTypes.STRING,
      validate: {
        isIn: {
          args: [availableAirports],
          msg: 'Invalid destination airport'
        }
      }
    },
    departureTime: {
      type: DataTypes.DATE,
      validate: {
        isDate: {
          args: true,
          msg: 'Invalid departure time'
        }
      }
    }
  }, {
    sequelize,
    modelName: 'FlightSchedule',
    validate: {

添加自定义模型验证器

现在,我们可以在这里添加我们的自定义模型验证器。我们将创建一个函数,该函数将检查值是否与 originAirportdestinationAirport 属性匹配。如果两个值都相同,我们将标记目的地为无效并抛出错误:

      validDestination() {
        const hasAirportValues = this.originAirport !== 
        null && this.destinationAirport !== null;
        const invalidDestination = this.originAirport === 
        this.destinationAirport;;
        if (hasAirportValues && invalidDestination) {
          throw new Error("The destination airport cannot 
                           be the same as the origin");
        }
      }

对于最后一步,我们将关闭任何对象或函数,并将类返回到导出:

    }
  });
  return FlightSchedule;
};

你可能已经注意到,如果两个值都是 null,我们就会跳过 validDestination 验证器。isIn 验证器仍然会执行并返回一个错误,因为没有有效的值。

在执行异步操作时执行验证

有时,你的验证可能需要你获取关联模型的记录,调用第三方应用程序,或进行其他形式的请求,这些请求需要等待响应。

假设我们处于这样一种情况,我们必须确保在创建或更新客户的会员积分之前,有一个完成且活跃的付款。只要任何付款仍然被认为是良好的,该客户应该能够更新他们的会员资格。我们会使用 asyncawait 关键字来帮助我们执行这些请求并等待验证的响应:

Membership.init({
  points: {
    type: DataTypes.INTEGER,
  }
}, {
  sequelize,
  validate: {
    async accountIsActive() {
      const payments = await Payments.find({
        where: { status: 'complete', expired: false }
      });
      if (payments.length < 1) {
        throw new Error("Invalid membership");
      }
    }
  }
});

注意

asyncawait 关键字同样适用于自定义属性验证器。

需要注意的是,如果你在生命周期事件中运行 Sequelize 查询,那么这些查询将在与父模型不同的事务下执行。例如,如果我们开始了一个事务,并在其作用域内创建了付款条目,然后尝试创建会员条目,那么 await Payments.find(…) 这一行将无法看到最近创建的记录。为了解决这个问题,我们可以在调用 create 时将 Sequelize 事务传递给 transaction 参数。以下是一个非常通用但高级的示例:

const tx = await sequelize.transaction();
try {
  await Payment.create({ status: 'complete', expired: false });
  await Membership.create({
    points: 100,
    // without the following line the `await    
Payments.find()` call in 
    // ...accountIsActive will not find the previously
created entry
    transaction: tx
  });
  await t.commit();
} catch (err) {
  await t.rollback();
}

处理验证错误

使用来自 创建自定义验证方法 部分的 FlightSchedule,我们将介绍如何在调用 validateupdatecreate 方法时处理验证错误。假设我们调用了一个 createFlightSchedule 方法,其外观如下:

const { ValidationError } = require('@sequelize/core');
// other imports and code...
async function createFlightSchedule() {
  try {
    await FlightSchedule.create({
      originAirport: 'JAX',
      destinationAirport: 'JFK',
      departureTime: '2030-01-01T00:00:00.000+00:00'
    });
  } catch (err) {
    if (err instanceof ValidationError) {
      console.log(err.errors);
    } else {
      console.log(err);
    }
  }
}
  return FlightSchedule;
};

默认情况下,从 ValidationError 返回的错误应该类似于以下内容(你可能还会看到列出的其他字段):

[
  ValidationErrorItem {
    message: 'Invalid origin airport',
    type: 'Validation error',
    path: 'originAirport',
    value: 'JAX',
    origin: 'FUNCTION',
    instance: FlightSchedule {
      dataValues: [Object],
      _previousDataValues: [Object],
      uniqno: 1,
      _changed: [Set],
      _options: [Object],
      isNewRecord: true
    },
    validatorKey: 'isIn',
    validatorName: 'isIn',
    validatorArgs: [ [Array] ],
    original: Error: Invalid origin airport {
      validatorName: 'isIn',
      validatorArgs: [Array]
    }
  }
] 
          origin airport'}
]

或者,我们可以在尝试使用实例的 validate() 方法创建或修改记录之前手动检查验证,如下所示:

async function createFlightSchedule() {
  try {
    const schedule = FlightSchedule.build({
      originAirport: 'JAX',
      destinationAirport: 'JFK',
      departureTime: '2030-01-01T00:00:00.000+00:00'
    });
    await schedule.validate();
  } catch (err) {
    console.log(err);
  }
}

结果将返回一个类似于以下错误对象:

{
  errors: [
    ValidationErrorItem {
      message: 'Invalid origin airport',
      type: 'Validation error',
      path: 'originAirport',
      value: 'JAX',
      origin: 'FUNCTION',
      instance: [FlightSchedule],
      validatorKey: 'isIn',
      validatorName: 'isIn',
      validatorArgs: [Array],
      original: [Error]
    }
  ]
} 
                          the same as the origin']
}

因此,现在我们的数据已经 一致,那么 完整性 呢?在下一章中,我们将介绍 Sequelize 为我们提供创建(以及操作)关联和模型之间各种关联方式的功能。

摘要

在本章中,我们通过使用 Sequelize 内置验证器以及添加我们自己的自定义验证方法,对我们的模型添加了验证。然后,我们转向在自定义验证中处理和执行异步方法。一旦我们能够正确调用验证,我们就能练习处理验证错误。

在下一章中,我们将介绍另一个为我们的数据库添加一致性和完整性的部分,即处理和关联我们的模型。验证将确保数据库行级别的完整性,而关联可以用来确保跨表和其他行的完整性。

第四章:模型关联

除了使用验证来确保数据库内部的一致性之外,我们还可以在两个表之间创建关联,以确保共生关系得到维护和更新。数据库通过创建外键引用来维护这些关系,这些引用包含有关外键关联的表和列的元数据。这些元数据维护数据库的完整性。如果我们不进行适当的引用更新外键的值,我们就必须执行一个单独的查询来更新所有引用外键的新值的行。

例如,我们有三张表:customersproductsreceiptsreceipts表将有两个列(除了其他列之外),分别引用customersproducts表上的列。如果我们想更新产品的标识列,我们只需修改相关产品的标识值。然后,引用receipts表中产品的行将自动更新。如果没有外键引用,我们必须在更新产品的标识后显式更新receipts表。

备注

传统上,外键会引用主键列或某种形式的标识列,但你并不局限于这些列。

模型之间的关系可以通过 Sequelize 自动管理,或者以可配置的方式管理,以采用现有的数据库。映射模型之间的关系可以帮助 ORM 根据环境通过预加载延迟加载形成高效的查询。

本章将涵盖以下主题:

  • 关联方法

  • 关系模式

  • 使用预加载和延迟加载查询关联

  • 使用高级关联选项

  • 将关联应用于 Avalon Airlines

技术要求

您可以在此处找到本章的代码文件:github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch4

关联方法

在 ORM 中创建关系映射有几个选项。通过 ORM 定义关系可以帮助自动构建具有正确属性和列的数据库,管理关联验证(例如,检查是否严格只有一个相关记录),并在检索或插入数据时执行查询优化模式。Sequelize 支持四种关联模式:

  • HasOne – 一种一对一关联,其中外键引用子模型。该属性在父模型中定义。

  • BelongsTo – 一种一对一关联,其中外键引用父模型。该属性在子模型中定义。

  • HasMany – 一种一对一关联,其中外键引用父模型。该属性在子模型中定义。

  • BelongsToMany – 一种多对多关联,其中将使用单独的模型(称为 连接表)存储关联模型的引用。

Sequelize 将遵循在具有关联的模型上创建方法的模式。根据关系,方法名称前缀可以是 getsetaddcreateremovecount,后面跟着关联的名称(在适用的情况下可以是单数或复数形式)。

在本节中,我们将介绍关联及其对应方法的列表。一旦我们完成了关联概述,我们就可以开始关系概述的模式。在本节中,我们将使用演员和戏剧/电影/职位的概念来帮助我们掌握模型关联属性和行为的基本原理。这些示例仅用于说明目的,不应包含在我们的项目代码库中。

注意

你可以在 get 关联方法中添加 where 子句语句(以及其他查找参数),如下所示:

Actor.getJobs({

where: { category: 'Action' },

limit: 10, offset: 20, /* etc. */

});

hasOne

hasOne 关联将为关联模型生成 getsetcreate 方法。假设我们有以下关联和实例:

Actor.hasOne(Job);
const actor = await Actor.create({ … });

我们可以使用 createJob 方法来插入并设置与演员相关的职位:

await actor.createJob({ name: '…' });

setJob 方法将更新演员和职位之间的关联,从 Job 实例:

const job = await Job.create({ name: '…' });
await actor.setJob(job);

你可以使用 set 前缀方法通过 null 值来移除关联:

await actor.setJob(null);

belongsTo

让我们改变之前示例中的模型为以下内容:

Actor.belongsTo(Job);

belongsTo 关联将在与 hasOne 关联相同的模型上生成完全相同的方法。为了进一步解释,此关联不会在 Job 模型上创建 setActor,但仍然会在 Actor 模型上创建 setJob 方法。

hasMany

hasMany 关联将生成以下前缀方法:getsetcreatecounthasaddremovegetset 方法与之前的示例类似,除了方法名称的后缀将是模型名称的复数形式:

Actor.hasMany(Job);
let jobs = await Job.findAll();
await Actor.setJobs(jobs);
await Actor.getJobs();

create 前缀方法仍然一次只创建一条记录,因此模型名称仍然是单数形式:

await Actor.createJob({ name: '…' });

我们还可以使用 has 方法检查是否存在关系:

const job = await Job.findOne();
// true or false boolean value
const hasJob = await Actor.hasJob(job);
// using jobs from our previous example
const hasAllJobs = await Actor.hasJobs(jobs);

我们可以使用 add 方法添加一个或多个关联,如下所示:

await Actor.addJob(job);
await Actor.addJobs(jobs);

要检索模型有多少关联,我们可以调用 count 方法:

// will return 2 following the examples in this section
await Actor.countJobs();

使用 remove 方法可以移除关联:

await Actor.removeJob(job);
await Actor.removeJobs(jobs);

belongsToMany

然后,我们将关联更改为 belongsToMany

Actor.belongsToMany(Job, { through: '...' });

belongsToMany 关联将在与之前 hasMany 示例相同的模型上生成相同的方法,类似于 belongsTo 生成与 hasOne 关联相同的方法。

重命名关联

您可以通过使用 as 参数创建关联的别名来修改 Sequelize 生成的方法名:

Actor.hasOne(Job, {
  as: 'gig'
});
const actor = await Actor.create({ … });
const gig = await Job.create({ … });
actor.createGig({ … });
actor.setGig(gig);
actor.hasGig(gig);

注意

您可以直接将关系的标识符设置为属性,Sequelize 将使用 save 方法更新该值。然而,如果您在关联记录中进行了任何更改,通过调用关联实例的 save 方法,它们的信息将不会被更新。对实际关联的更改需要从它们自己的实例中进行。

现在我们知道了如何将关联方法应用于我们的模型,我们可以回顾各种关系模式,以帮助我们更好地理解这些关联在哪里以及何时被耦合。在下一节中,我们将详细介绍 Sequelize 支持的关系模式以及每个模式的示例。

关联模式

在本节中,我们将详细介绍每种类型的关系(除了将在下一节中讨论的超多对多关系),以及如何使用 Sequelize 定义关联。之后,我们将更新 Avalon Airlines 项目的模型以包含关联。

我们可以将几个关联模式组合起来定义一个关系模式。Sequelize 支持四种关系模式:

  • 一对一 – 我们将一起使用 hasOnebelongsTo 关联。

  • 一对多 – 使用 hasManybelongsTo 关联来实现此模式。

  • 多对多 – 使用两个 belongsToMany 关联来实现此模式。

  • 超多对多 – 两个 One-to-Many 关系,其中 One 模型仍然被认为是相互依存的。这种关系将在 创建超多对多关系 部分中进一步详细说明。

一对一

一对一关系模式涉及模型之间的 hasOnebelongsTo 关联。这两个关联之间的区别在于哪个表将包含标识列。

例如,我们有一个 AirplaneBoardingTicket 模型。由于 Airplane 模型在航班完成后将不再与 BoardingTicket 相关,我们可以从 Airplane 模型的表中省略对登机牌的记忆。这意味着 Airplane 不需要 hasOne 关联,但 BoardingTicket 仍然需要一个 belongsTo 关联。

要定义一对一关系,我们会这样定义我们的模型:

const A = sequelize.define('A', { … });
const B = sequelize.define('B', { … });
A.hasOne(B);
B.belongsTo(A);

使用 Sequelize 的 sync 命令会产生以下查询:

CREATE TABLE IF NOT EXISTS "b" (
  /* ... */
);
CREATE TABLE IF NOT EXISTS "b" (
  /* ... */
  "aId" INTEGER REFERENCES "a" ("id") ON DELETE SET NULL ON UPDATE CASCADE
  /* ... */
);

注意

如果不调用 A.hasOne(B),Sequelize 将不知道如何从模型 A 到 B 进行预加载(但可以从模型 B 到 A 进行预加载)。

有几种选项可以作为第二个参数传递以调整关联的行为:

  • onUpdate – 告诉数据库管理系统如何处理更新的外键关系。可能的值有 CASCADERESTRICTNO ACTIONSET DEFAULTSET NULL。此选项的默认值为 CASCADE

  • onDelete – 与 onUpdate 相同,但用于处理已删除的外部关系。此选项的默认值为 SET NULL

  • foreignKey – 接受一个字面字符串值或一个对象,该对象在定义模型时具有与属性相同的选项(nameallowNullunique 等)。

  • sourceKey – 用于识别外键列值的源表列的名称。默认情况下,Sequelize 将使用具有 primaryKey: true 参数的源表属性。如果您的模型不包含显式的 primaryKey 属性,则 Sequelize 将使用默认的 id 属性。适用于 hasOnehasMany 关联。

  • targetKey – 与 sourceKey 类似,但此值将引用目标表中的列,而不是源表中的列。适用于 belongsTo 关联。

这里有一些如何使用这些选项的示例:

A.hasOne(B, {
    onUpdate: 'SET NULL',
    onDelete: 'CASCADE',
    foreignKey: 'otherId'
});
B.belongsTo(A);
A.hasOne(B, {
    onUpdate: 'CASCADE',
    onDelete: 'SET NULL',
    foreignKey: { name: 'otherId' }
});
B.belongsTo(A);

您可以在模型 A 和 B 之间互换使用第二个选项:

A.hasOne(B);
B.belongsTo(A, {
    onUpdate: 'SET NULL',
    onDelete: 'CASCADE',
    foreignKey: 'otherId'
});
A.hasOne(B);
B.belongsTo(A, {
    onUpdate: 'CASCADE',
    onDelete: 'SET NULL',
    foreignKey: { name: 'otherId' }
});

默认情况下,Sequelize 会将一对一关系设置为可选,但如果我们想要在两个模型之间强制关系,那么我们会在关联选项中将 allowNull 定义为 false,如下所示:

A.hasOne(B, {
  foreignKey: { allowNull: false }
});

一对多

这种关系模式将仅在 belongsTo 模型上创建外键引用列。使用 hasMany 关联定义属性有助于 Sequelize 进行数据检索优化,并为父模型添加辅助函数。第二个参数中的选项与一对一关系相同。

假设我们有一个 Employees 模型属于 Organization。使用 Sequelize,代码将类似于以下内容:

Organization.hasMany(Employee);
Employee.belongsTo(Organization);

这将执行以下几个查询:

CREATE TABLE IF NOT EXISTS "Organizations" (
  /* ... */
);
CREATE TABLE IF NOT EXISTS "Employees" (
  /* ... */
  "OrganizationId" INTEGER REFERENCES "Organizations" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
  /* ... */
);

多对多

这种关系将使用关联实体来保持两个模型之间的引用。关联实体的其他名称包括连接表、连接模型、交叉引用表和配对表。使用 sequelize.sync(),Sequelize 将自动为您创建连接模型,但我们仍然可以选择定义自己的连接表,以便在需要添加更多属性、约束、生命周期事件等情况下使用。

在此示例中,我们有一些员工被分配了任务。员工可以处理多个任务,而任务可能需要多个员工:

Employee.belongsToMany(Task, { through: 'EmployeeTasks' });
Task.belongsToMany(Employee, { through: 'EmployeeTasks' });

这将执行以下查询:

CREATE TABLE IF NOT EXISTS "EmployeeTasks" (
    "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
    "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
    "EmployeeId" INTEGER REFERENCES "Employees" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
    "TaskId" INTEGER REFERENCES "Tasks" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
    PRIMARY KEY ("EmployeeId","TaskId")
);

备注

多对多关系将使用 CASCADE 作为默认行为来管理更新和删除时的外键和关系。

如果我们希望在定义中更加明确,或者想要为连接模型添加自定义属性,我们可以这样定义连接模型和关系:

// Employee and Task are pre-defined for brevity
const EmployeeTasks = sequelize.define('EmployeeTasks', {
    EmployeeId: {
      type: DataTypes.INTEGER,
      references: {
        model: Employee,
        key: 'id'
      }
    },
    TaskId: {
      type: DataTypes.INTEGER,
      references: {
        model: 'Tasks', // string literal values work here 
                           too
        key: 'id'
      }
    },
    SomeOtherColumn: {
        type: DataTypes.STRING
    }
});
Employee.belongsToMany(Task, {
    through: EmployeeTasks
});
Task.belongsToMany(Employee, {
    through: EmployeeTasks
});

除了 through 参数外,多对多关联还提供了一个名为 uniqueKey 的参数,这将允许你命名一个引用列。默认情况下,Sequelize 将创建一个由引用列(employeeIdtaskId)组成的唯一键的连接表。如果你希望改变这种行为,你可以在连接模型定义中将 unique 属性参数设置为 false

使用自定义外键定义

在何时以及如何正确使用 sourceKeytargetKey 可能一开始会让人困惑。hasOnehasMany 关联将从父模型(目标模型)引用到子模型(源模型);另一种说法是,“这个孩子通过名字、婚姻等是我的。”belongsTo 关联将引用父模型,或者说,“我通过名字、婚姻等属于这个父模型。”

我们将这些模型称为 目标 模型,因为父亲和子代可能会产生误导,并暗示某种形式的层次结构。关联不需要层次结构;它们只是形成关系。

注意

被定义为引用的属性必须应用唯一约束。这可以通过在属性选项中添加 unique: true 并使用 Sequelize 的 sync() 方法来实现。

为了说明如何配置源和目标键,我们首先定义我们的模型:

const Actor = sequelize.define('Actors', {
    name: {
        type: DataTypes.TEXT,
        unique: true
    }
});
const Role = sequelize.define('Roles', {
    title: {
        type: DataTypes.TEXT,
        unique: true
    }
});
const Costume = sequelize.define('Costumes', {
    wardrobe: {
        type: DataTypes.TEXT,
        unique: true
    }
});

使用这些角色,我们将分别通过 hasOnehasManybelongsTobelongsToMany 的示例来展示。

使用 hasOne

以下代码将在 Roles 模型上创建一个名为 actorName 的属性,该属性的值将与演员的名字相关联(而不是演员的 ID 属性):

Actor.hasOne(Role, {
    sourceKey: 'name',
    foreignKey: 'actorName'
});

使用 hasMany

我们将为 hasMany 关联使用相同的选项。以下代码将在 Costumes 模型上创建一个名为 roleTitle 的属性,它将与角色的标题相关联:

Roles.hasMany(Costumes, {
    sourceKey: 'title',
    foreignKey: 'roleTitle'
});

使用 belongsTo

belongsTo 关联的工作方式略有不同。它不是从源模型引用,而是从目标模型引用,如下所示(这将产生与之前 Actors.hasOne(Roles, ...) 代码相同的结果):

Roles.belongsTo(Actors, {
    targetKey: 'name',
    foreignKey: 'actorName'
});

换句话说,当使用 belongsTo 时,外键将放置在创建关联的模型上,而对于 hasOne/hasMany 关联,外键将放置在未调用关联方法的另一个模型上。

使用 belongsToMany

belongsToMany 关联接受目标和源键作为参数来配置引用。在电影场景中,一个演员可能为他们的所有不同场景拥有多个服装。我们可以在 Sequelize 中这样表示这种关系:

Costumes.belongsToMany(Actors, {
    through: 'actor_costumes',
    sourceKey: 'name',
    targetKey: 'wardrobe'
});

这将创建一个名为 actor_costumes 的连接表,其中包含两个引用列 actorsNamecostumesWardrobe,分别引用 Actor 和 Costume 模型。

现在,我们已经讨论了 Sequelize 关联模式、选项、用例和示例的定义、选项、用例和示例,以及主要的三种关系模式,我们可以开始练习在选择或修改记录时包含这些关系。

使用急加载和懒加载查询关联

Sequelize 根据您希望如何查询数据提供两种不同的查询关联方法:急加载和懒加载。使用急加载,您将一次性加载所有关联数据。懒加载方法将根据代码中的调用逐个加载关联。与懒加载相比,急加载更容易解释,但要看到急加载的好处,我们首先需要了解懒加载。

注意

您可能听说过其他 ORM 框架中的“N+1 查询问题”;这指的是懒加载方法(尽管不是相互排斥)以及逐行选择关联如何可能对应用程序的性能造成危害。

懒加载

Sequelize 尽量不对您的意图做出假设,并且最初只选择模型数据。如果我们想遍历模型的相关数据,我们需要明确调用关联。懒加载的一个良好用例是条件查询相关数据(例如,我们可能不想在电影发布后获取电影评论)。懒加载看起来可能如下所示:

const actor = await Actor.findOne();
// SELECT * FROM jobs WHERE actorId=?
const job = await Actor.getJob();
let reviews = [];
if (job.isDone) {
  // SELECT * FROM reviews WHERE jobId=?
  reviews = await job.getReviews({
    where: { published: true }
  });
}

急加载

通常,您会在有很多关联或主表返回大量行时使用这种加载形式。参考之前的示例,假设我们用 getJobs 替换了 getJob,并且对每个工作调用 getReviews,如下所示:

const jobs = await Actor.getJobs();
let reviews = [];
jobs.map(async job => {
  if (job.isDone) {
    let jobReviews = await job.getReviews({
      where: { published: true }
    });
    reviews = reviews.concat(jobReviews);
  }
});

如果演员变得过于著名并且有数百个工作,我们可以看到查询数量如何迅速变得对系统来说过于繁琐。防止这种情况的一种方法是通过使用急加载方法,该方法将使用 JOIN 语句在顶级查询中包含关联数据。让我们通过开始定义我们的已完成工作关联来将之前的示例转换为 Sequelize 的急加载查询:

const completedJobs = {
    model: Job,
    as: 'CompletedJobs',
    where: {
        completed: true
    },
    include: {
        model: Review,
        where: {
            published: true
        }
    }
}

这个第一个 include 参数将加载 Job 模型,设置别名为 CompletedJobs,添加一个针对 completed 标志的 where 子句,然后从 JobReview 的嵌套关联调用(以及针对评论的 published where 子句)。

接下来,我们需要定义我们的不完整工作关联:

const incompleteJobs = {
    model: Job,
    as: 'IncompleteJobs',
    where: {
        completed: false
    }
}

第二个参数是一个更简单的 Job 别名关联,它从 CompletedJobs 中反转了 where 子句。

这里的想法是分别查询已完成和未完成的工作,因为我们只想包括已完成的工作的评论。现在,我们可以使用工作和评论来查询我们的演员:

const actor = await Actor.findOne({
    include: [ completedJobs, incompleteJobs ]
});

这将生成一个类似的 SQL 查询(为了简洁,省略了一些选定的列):

SELECT
    `Actor`.*,
    `CompletedJobs`.`title` AS `CompletedJobs.title`,
    `CompletedJobs`.`completed` AS `CompletedJobs.completed`,
    `CompletedJobs->Reviews`.`id` AS `CompletedJobs.Reviews.     id`,
    `CompletedJobs->Reviews`.`published` AS `CompletedJobs.
     Reviews.published`,

    `IncompleteJobs`.`title` AS `IncompleteJobs.title`,
    `IncompleteJobs`.`completed` AS `IncompleteJobs.completed`

FROM (
    SELECT `Actor`.`id`, `Actor`.`name`, `Actor`.`createdAt`, 
    `Actor`.`updatedAt`
    FROM `Actors` AS `Actor`
    LIMIT 1
) AS `Actor`
LEFT OUTER JOIN `Jobs` AS `CompletedJobs` ON
    `Actor`.`id` = `CompletedJobs`.`ActorId` AND
    `CompletedJobs`.`completed` = true
LEFT OUTER JOIN `Reviews` AS `CompletedJobs->Reviews` ON
    `CompletedJobs`.`id` = `CompletedJobs->Reviews`.`JobId` AND
    `CompletedJobs->Reviews`.`published` = true
LEFT OUTER JOIN `Jobs` AS `IncompleteJobs` ON
    `Actor`.`id` = `IncompleteJobs`.`ActorId` AND
    `IncompleteJobs`.`completed` = false;

然后,将已完成和未完成的工作连接起来:

const jobs = [].concat(
  actor.CompletedJobs,
  actor.IncompleteJobs
);

现在我们可以遍历工作,并在适用的情况下显示评论:

jobs.forEach(job => {
  const reviews = job.Reviews || [];
  // display reviews here
});

现在我们已经了解了 Sequelize 两种加载类型的基础,我们可以开始探索在关联数据时更高级的概念。在下一节中,我们将讨论关联的更高级查询模式:超级多对多关联和多态关联。

使用高级关联选项

Sequelize 提供了一系列技巧来帮助正确地与数据库的关系进行通信。其中一些方法将帮助以更有组织和更舒适的方式查询关联。其他方法将为我们提供一种方式来组合数据库的架构,以实现更高级的关系模式。在本节中,我们将讨论如何使用超级多对多模式管理复杂的多对多关系、定义作用域关联以及查询多态关联的示例。

使用关联的作用域

作用域是一种定义命名空间的方式,它包含一组默认参数,或者覆盖之前应用于查询的参数。关联可以定义作用域以帮助组织代码库。关联作用域与模型作用域之间的一个关键区别是,关联作用域的参数适用于WHERE子句。模型作用域可以定义WHERELIMITOFFSET子句等。

以下是一个查询具有作用域的关联的示例:

const Worker = sequelize.define('worker', { name: DataTypes.STRING });
const Task = sequelize.define('task', {
  title: DataTypes.STRING,
  completed: DataTypes.BOOLEAN
});
Worker.hasMany(Task, {
    scope: {
        completed: true
    },
    as: 'completedTasks'
});
const worker = await Worker.create({ name: "Bob" });
await worker.getCompletedTasks();

Sequelize 将为worker实例添加一个名为getCompletedTasks()的混入,该混入将执行一个类似于以下查询的操作:

SELECT `id`, `completed`, `workerId`
FROM `tasks` AS `task`
WHERE `task`.`completed` = true AND `task`.`workerId` = 1;

task.completed = true这一部分是 Sequelize 根据作用域定义自动添加的。定义相同作用域的另一种方式如下所示:

Task.addScope('completed', {
    where: { completed: true }
});
Worker.hasMany(Task.scope('completed'), {
  as: 'completedTasks'
});

当创建具有作用域的关联时,Sequelize 会在调用createadd混入函数时自动为这些参数添加默认值。例如,我们知道一个工人已经完成了一个任务,并希望插入关联,如下所示:

const worker = Worker.findOne();
await worker.createCompletedTask({ title: 'Repair Cluster' });

当通过worker创建一个完成的任务时,Sequelize 将执行一个类似的查询:

INSERT INTO "tasks" (
    "id", "title", "completed"
) VALUES (
    DEFAULT, 'Repair Cluster', true, 1
) RETURNING *;

当我们想要添加一个完成的任务时,completed属性已被自动设置为true。如果我们使用add混入,也会表现出相同的行为。

belongsToMany关联中使用作用域参数会将作用域应用于目标模型而不是连接模型。如果您希望将作用域应用于连接模型,您可以在through配置内部添加作用域参数,如下所示:

const WorkerTask = sequelize.define('WorkerTask', {
  published: DataTypes.BOOLEAN
});
Worker.belongsToMany(Task.scope('completed'), {
  through: {
    model: WorkerTasks,
    scope: { published: true }
  },
  as: 'CompletedAndPublishedTask'
});

创建超级多对多关系

假设我们拥有一家商店,并希望通过事务维护员工与客户之间的关联列表。通常,我们可以通过在EmployeeCustomer模型上添加一个belongsToMany关联,并使用Transaction模型作为连接表来定义这种关系。

让我们从本节中使用的这些模型定义开始:

const Employee = sequelize.define('employee', {
  name: DataTypes.STRING,
});
const Customer = sequelize.define('customer', {
  name: DataTypes.STRING
});
const Transaction = sequelize.define('transaction', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  },
  couponCode: DataTypes.STRING
});

你可能已经注意到,Transaction 模型的 id 属性被明确定义为主键。这将防止 Sequelize 使用 employeeIdcustomerId 的组合键作为主键,这对于建立超级多对多关系是必需的。

有两种方法可以创建这三个模型之间的多对多关系。常见的方法是使用 belongsToMany 关联,如下所示:

Employee.belongsToMany(Customer, { through: Transaction });
Customer.belongsToMany(Employee, { through: Transaction });

另一种方法是同时在关联模型上使用 hasManybelongsTo

Employee.hasMany(Transaction);
Transaction.belongsTo(Employee);
Customer.hasMany(Transaction);
Transaction.belongsTo(Customer);

这两种方法将为连接表产生相同的图示结果(在连接模型上创建 employeeIdcustomerId)。然而,当你尝试预加载数据时,可能会遇到几个问题,具体取决于你如何查询数据。

使用 belongsToMany 关联,我们可以按以下方式查询我们的模型(然而,这不会对 hasManybelongsTo 方法起作用):

Employee.findAll({ include: Customer });
Customer.findAll({ include: Employee });

从关联模型中包含连接模型对于 belongsToMany 方法不起作用,但以下代码对于 hasManybelongsTo 方法是有效的:

Employee.findAll({ include: Transaction });
Customer.findAll({ include: Transaction });

尝试通过连接模型包含关联模型仅适用于 hasManybelongsTo 方法。以下代码对 belongsToMany 模式不起作用:

Transaction.findAll({ include: Employee });
Transaction.findAll({ include: Customer });

为了能够使用这些模型的所有各种预加载形式,我们可以通过组合两种关联方法(如下所示)来实现超级多对多模式:

Employee.belongsToMany(Customer, { through: Transaction });
Customer.belongsToMany(Employee, { through: Transaction });
Employee.hasMany(Transaction);
Transaction.belongsTo(Employee);
Customer.hasMany(Transaction);
Transaction.belongsTo(Customer);

以这种方式声明我们的关联将允许我们通过连接模型或关联模型本身查询关联数据,而不受约束或规定。深度嵌套的包含也通过超级多对多关系原生支持。

使用多态关联

当我们有两个或更多关联模型针对连接表上的同一外键时,我们可以为该场景使用多态关联模式。你可以将多态关联视为关联数据的某种通用接口。

假设我们拥有一家在线零售店,并希望将 widget 和 gizmo 的评论存储在一个评论表中。最初,我们可能想使用 hasManybelongsTo 关联,但这会导致 Sequelize 在连接模型上生成两个列(widgetIdgizmoId),而不是一个通用的模式。从语义上讲,这也不合理,因为评论既不与 widget 相关,也不与 gizmo 产品相关。

首先,我们需要定义我们的 WidgetGizmo 模型:

const Widget = sequelize.define('Widget', {
  sku: DataTypes.STRING,
  url: DataTypes.STRING
});
const Gizmo = sequelize.define('Gizmo', {
  name: DataTypes.STRING
});

接下来,我们将定义我们的 Review 模型如下:

const Review = sequelize.define('Review', {
  message: DataTypes.STRING,
  entityId: DataTypes.INTEGER,
  entityType: DataTypes.STRING
}, {
    instanceMethods: {
        getEntity(options) {
if (!this.entityType) return Promise.resolve(null);
const mixinMethodName = `get${this.entityType}`;
            return thismixinMethodName;
        }
    }
});

instanceMethods 参数将为每个实例创建一个 getEntity 函数,该函数将检查 entityType 是否为空。如果有实体类型,则可以通过在实体类型的值前添加 get 前缀来调用关联的混合函数。

现在我们可以建立我们的关系,如下所示:

Widget.hasMany(Review, {
  foreignKey: 'entityId',
  constraints: false,
  scope: {
    entityType: 'Widget'
  }
});
Review.belongsTo(Widget, { foreignKey: 'entityId', 
                           constraints: false });
Gizmo.hasMany(Review, {
  foreignKey: 'entityId',
  constraints: false,
  scope: {
    entityType: 'Gizmo'
  }
});
Review.belongsTo(Gizmo, { foreignKey: 'entityId', 
                          constraints: false });

对于每个关联,我们将使用entityId作为foreignKey,由于连接模型引用了多个表,我们无法在该表上设置引用约束(这就是为什么我们将constraints设置为false):

Review.addHook("afterFind", findResult => {
if (!Array.isArray(findResult)) findResult = [findResult];
  for (const instance of findResult) {
    if (instance.entityType === "Widget" && instance.Widget 
        !== undefined) {
      instance.entity = instance.Widget;
    } else if (instance.entityType === "Gizmo" && in
               stance.Gizmo !== undefined) {
      instance.entity = instance.Gizmo;
    }
  }
});

要查询关联,请执行以下操作:

const widget = await Widget.create({ sku: "WID-1" });
const review = await widget.createReview({ message: "it works!" });
// the following should be true
console.log(review.entityId === widget.id);

多态允许我们通过调用实例方法getEntity来检索小部件或小玩意,而无需在我们的查询中预先确定:

const entity = await review.getEntity();
// widget and entity should be the same object and return 
   "true" for deep comparison checking
const isDeepEqual = require('deep-equal');
console.log(isDeepEqual(widget, entity));

为了懒加载我们的数据,我们将包括关联模型,就像任何其他懒加载查询一样:

const reviews = await Review.findAll({
    include: [Widget, Gizmo]
});
for (const review of reviews) {
    console.log('Found a review with the following entity: 
                 ', review.entity.toJSON());
}

afterFind钩子将自动将create实例关联到每个评论的entity键。

由于我们引用了多个表到一个目标列,所以在查询连接模型时需要格外小心。例如,如果WidgetGizmo都有一个 ID 为5,并且一个评论有entityTypeGizmo,如果我们查询带有Review.findAll({ include: Widget })的评论,那么Widget实例将被懒加载到Review中,无论entityType是什么。

Sequelize 不会自动从模型名称中推断实体的类型。幸运的是,我们的afterFind生命周期事件将正确分配实体值。建议始终使用抽象方法(例如,getEntity)而不是 Sequelize 的混入(例如,getWidgetgetGizmo等),以避免歧义。

到目前为止,我们已经展示了一个一对多关系的示例,但多对多关系呢?使用之前的示例模型,我们可以添加一个名为Categories的关联模型,其外观如下:

const Category = sequelize.define('Category', {
    name: DataTypes.STRING
}, {
    instanceMethods: {
        getEntities(options) {
            const widgets = await this.getWidgets(options);
            const gizmos = await this.getGizmos(options);
            return [].concat(widgets, gizmos);
        }
    }
});

现在,我们可以通过分配两个外键列来创建用于多对多关系的连接模型:

const CategoryEntity = sequelize.define('CategoryEntity', {
    categoryId: {
        type: DataTypes.INTEGER,
        unique: 'ce_unique_constraint'
    },
    entityId: {
        type: DataTypes.INTEGER,
        unique: 'ce_unique_constraint',
        references: null
    },
    entityType: {
        type: DataTypes.STRING,
        unique: 'ce_unique_constraint'
    }
});

ce_unique_constraint行将告诉 Sequelize 这三个属性属于同一个复合唯一键。entityId的空引用将确保 Sequelize 不会为该列创建引用约束。

接下来,我们可以定义WidgetGizmo的关系以及一个辅助方法。我们将分配一个公共参数配置以及一个用于修改作用域的函数:

const throughJunction = {
    through: {
      model: CategoryEntity,
      unique: false
    },
    foreignKey: 'entityId',
    constraints: false
};
function scopeJunction(scope) {
    let opts = throughJunction;
    opts.through.scope = {
        entityType: scope
    };
    return opts;
}

然后,我们可以将我们的关系分配给适用的模型:

Widget.belongsToMany(Category, scopeJunction('Widget'));
Category.belongsToMany(Widget, throughJunction);

Gizmo.belongsToMany(Category, scopeJunction('Gizmo'));
Category.belongsToMany(Gizmo, throughJunction);

调用一个方法,例如widget.getCategories(),将执行一个类似于以下查询的操作:

SELECT
    `Category`.`id`,
    `Category`.`name`,
    `CategoryEntity`.`categoryId` AS `CategoryEntity.
     categoryId`,
    `CategoryEntity`.`entityId` AS `CategoryEntity.entityId`,
    `CategoryEntity`.`entityType` AS `CategoryEntity.
     entityType`,
FROM `Categories` AS `Category`
INNER JOIN `CategoryEntities` AS `CategoryEntity` ON
    `Category`.`id` = `CategoryEntity`.`categoryId` AND
    `CategoryEntity`.`entityId` = 1 AND
    `CategoryEntity`.`entityType` = 'Widget';

现在我们已经学会了如何使用 Sequelize 操作关联和关系,我们可以开始对我们的 Avalon 航空公司项目进行一些修改。

将关联应用于 Avalon 航空公司

幸运的是,这个项目的模型很简单,不需要像定义一个超级多对多关系那样付出大量的努力。在接下来的几个模型更新中,这本书将展示只需要修改class模型块的内容(每个文件中的其余内容应保持不变)。

从字母顺序开始,我们将通过添加与FlightSchedule的关系来修改models/airplane.js文件的class块:

class Airplane extends Model {
  static associate(models) {
this.FlightSchedules =
this.hasMany(models.FlightSchedule);
  }
};

接下来,我们可以编辑 models/boardingticket.js 文件的 class 块并添加 CustomerFlightSchedule 关联:

class BoardingTicket extends Model {
  static associate(models) {
    this.Customer = this.belongsTo(models['Customer']);
    this.FlightSchedule = this.belongsTo(models['FlightSchedule']);
  }
};

客户现在将有许多登机牌;models/customer.js 文件的 class 块现在应该看起来像这样:

class Customer extends Model {
  static associate(models) {
this.BoardingTickets = 
this.hasMany(models.BoardingTicket);
  }
};

飞行计划将属于特定的飞机,并且将有许多登机牌。我们可以编辑 models/flightschedule.js 文件的 class 块以匹配以下示例:

class FlightSchedule extends Model {
  static associate(models) {
    this.Airplane = this.belongsTo(models['Airplane']);
    this.BoardingTickets = this.hasMany(models['BoardingTicket']);
  }
};

由于我们在初始化 Sequelize 时没有执行 sync({ force: true }),我们需要生成一个迁移文件并为相应的模型添加必要的引用。我们可以使用 Sequelize CLI 工具通过 migration:generate 子命令来生成一个新的迁移文件:

sequelize migration:generate --name add-references

Sequelize 将通过类似以下的消息通知你它已生成迁移文件:

New migration was created at /Users/book/migrations/20211031155604-add-references.js .

文件名上的前缀数字将不同于你屏幕上显示的数字,但如果我们查看 migrations 目录,我们将看到一个新的生成文件。我们可以快速覆盖文件的内容。

在文件顶部,我们将希望包含 DataTypes 并开始迁移的 up 块:

const { DataTypes } = require("@sequelize/core");
module.exports = {
  up: async (queryInterface, Sequelize) => { 

我们现在可以通过先添加列然后添加约束来为 FlightSchedule 模型添加对 Airplane 模型的引用:

    await queryInterface.addColumn('FlightSchedules', 'AirplaneId', {
      type: DataTypes.INTEGER,
    });
    await queryInterface.addConstraint('FlightSchedules', {
      type: 'foreign key',
      fields: ['AirplaneId'],
      references: {
        table: 'Airplanes',
        field: 'id'
      },
      name: 'fkey_flight_schedules_airplane',
      onDelete: 'set null',
      onUpdate: 'cascade'
    });

接下来,我们希望对 BoardingTicket 及其相关模型 CustomerFlightSchedule 做同样的处理:

    await queryInterface.addColumn('BoardingTickets', 'CustomerId', {
      type: DataTypes.INTEGER,
    }
);    await queryInterface.addConstraint('BoardingTickets', {
      type: 'foreign key',
      fields: ['CustomerId'],
      references: {
        table: 'Customers',
        field: 'id'
      }
,      name: 'fkey_boarding_tickets_customer',
      onDelete: 'set null',
      onUpdate: 'cascade'
    });
    await queryInterface.addColumn('BoardingTickets',  
                                   'FlightScheduleId', {
      type: DataTypes.INTEGER,
    });
    await queryInterface.addConstraint('BoardingTickets', {
      type: 'foreign key',
      fields: ['FlightScheduleId'],
      references: {
        table: 'FlightSchedules',
        field: 'id'
      },
      name: 'fkey_boarding_tickets_flight_schedule',
      onDelete: 'set null',
      onUpdate: 'cascade'
    });

现在,我们可以关闭 up 块并开始我们的 down 块以支持迁移反转:

  },
  down: async (queryInterface, Sequelize) => {

我们需要首先删除约束,然后是列,最后关闭 down 块和导出对象,如下所示:

    await queryInterface.removeConstraint(
      'FlightSchedules', 'fkey_flight_schedules_airplane'
    );
    await queryInterface.removeConstraint(
      'BoardingTickets', 'fkey_boarding_tickets_customer'
    );
    await queryInterface.removeConstraint(
      'BoardingTickets', 
      'fkey_boarding_tickets_flight_schedule'
    );
    await queryInterface.removeColumn('FlightSchedules', 
       'AirplaneId');
    await queryInterface.removeColumn('BoardingTickets', 
       'CustomerId');
    await queryInterface.removeColumn('BoardingTickets', 
    'FlightScheduleId');
  }
};

在我们的控制台中,我们可以使用以下命令来迁移这些新更改:

sequelize db:migrate

Sequelize 将确认迁移已完成,并且我们的模型通过关联正式相互关联。在本书的整个过程中,我们将使用本章学到的知识在查询中包含关联数据,但到目前为止,我们将继续进行下一课。

摘要

在本章中,我们讨论了使用关联属性以及一些高级选项和关系模式来定义模型的关系,并探讨了 eager loading 和 lazy loading 之间的区别。在本章的结尾,我们从前一章的“验证模型”中汲取经验,为 Avalon Airlines 项目添加了验证、关联和迁移。

注意

如果你曾在关联上遇到困难并需要快速参考,相关材料可以在这里找到:sequelize.org/docs/v6/core-concepts/assocs/

在下一章中,我们将讨论 Sequelize 的钩子功能(也称为生命周期事件),如何为模型定义钩子,以及生命周期事件的一些良好用例。

第五章:将钩子和生命周期事件添加到您的模型中

ORM 通常提供一种方法,使我们能够在执行某些操作时发生的事件中转换状态或对象。这些方法通常被称为钩子、生命周期事件、对象生命周期,甚至回调(后者在 Node.js 社区中不常用,因为与 Node.js 的原生环境存在命名冲突)。通常,这些方法有一个时间前缀(例如,beforeafter)在事件名称之前。

ORM 在其整个生命周期中对事件的要求没有严格的规则。ORM 中通常包括的事件被称为:验证、保存、创建、更新和销毁。其他 ORM 框架提供更广泛的事件范围或更细粒度的控制,例如在连接到数据库之前/之后、定义您的模型以及调用查找查询时。

Sequelize 将钩子分为全局钩子和局部钩子。全局钩子用于为每个模型定义默认的生命周期事件,强制事件(在 Sequelize 中称为永久钩子),以及与连接相关的事件。局部钩子包括在模型上定义的实例/记录的生命周期事件。

在本章中,我们将介绍以下内容:

  • 生命周期事件的执行顺序

  • 定义、删除和执行生命周期事件

  • 在关联和事务中使用生命周期事件

注意

您可以始终参考 Sequelize 的代码库来维护一个可用的生命周期事件列表:sequelize.org/docs/v6/other-topics/hooks/

技术要求

您可以在github.com/PacktPublishing/Supercharging-Node.js-Application-with-Sequelize/blob/main/ch5找到本章的代码文件。

生命周期事件的执行顺序

当我们想要引入超出数据库引擎范围的项目特定行为/约束时,生命周期事件是一个重要的特性。了解生命周期事件只是方程的一半,另一半是了解这些生命周期事件何时被触发。

假设我们被分配了一个任务,即向所有员工免费提供我们的产品。第一个动作可能是添加一个 beforeValidate 钩子,如果用户是员工,则将事务的小计设置为 0。这对我们来说很简单,但不幸的是,这对会计部门来说是一个噩梦。更好的方法是在 beforeValidatebeforeCreate 钩子中添加一个表示员工折扣的额外项目。

知道使用哪些生命周期事件的真实答案取决于项目的需求。从我们之前的例子中,一些交易需要移动法定货币,这涉及到先向员工收费,然后作为单独的交易提供退款/信用。在这种情况下,我们无法使用beforeValidatebeforeCreate,但afterCreate可能适用。在 Sequelize 的上下文中,知道在哪里放置你的代码逻辑就是知道生命周期事件的操作顺序。

在 Sequelize 中,生命周期事件遵循before/after前缀风格命名钩子,就像其他 ORM 框架一样。Sequelize 的所有连接生命周期事件都定义在sequelize对象本身上,所有实例事件类型都定义在模型上。模型事件类型可以在两个地方定义。这些规则的例外情况是我们想要为所有模型全局定义实例事件(以下章节将提供示例)。以下是按执行顺序列出生命周期事件及其回调函数签名的表格:

钩子定义按生命周期执行顺序排序

事件名称 事件类型 需要同步
beforeConnect(config)``beforeDisconnect(connection) 连接
beforeSync(options)``afterSync(options) 连接
beforeBulkSync(options)``afterBulkSync(options) 连接
beforeQuery(options, query) 连接
beforeDefine(attributes, options)``afterDefine(factory) 连接(模型)
beforeInit(config, options)``afterInit(sequelize) 连接(模型)
beforeAssociate({ source, target, type }, options)``afterAssociate({ source, target, type, association }, options) 连接(模型)
beforeBulkCreate(instances, options)``beforeBulkDestroy(options)``beforeBulkRestore(options)``beforeBulkUpdate(options) 模型
beforeValidate(instance, options) 实例
afterValidate(instance, options)``validationFailed(instance, options, error) 实例
beforeCreate(instance, options)``beforeDestroy(instance, options)``beforeRestore(instance, options)``beforeUpdate(instance, options)``beforeSave(instance, options)``beforeUpsert(values, options) 实例
afterCreate(instance, options)``afterDestroy(instance, options)``afterRestore(instance, options)``afterUpdate(instance, options)``afterSave(instance, options)``afterUpsert(created, options) 实例
afterBulkCreate(instances, options)``afterBulkDestroy(options)``afterBulkRestore(options)``afterBulkUpdate(options) 实例
afterQuery(options, query) 连接
beforeDisconnect(connection)``afterDisconnect(connection) 连接

*这些生命周期事件只有在调用sequelize.sync()时才会触发。

这些生命周期事件中的大多数都与它们的 Sequelize 函数(例如,beforeSave 对应于 Model.save())相对应。然而,有两种类型的事件是隐含的,并且可能一开始并不明显。第一种是与偏执模型(记录被视为通过列标志标记为delete而不是物理删除)相关的restore事件。第二种是Upsert事件,这些事件在createupdatesave相关方法中被调用,向我们指示记录是新建的还是从现有记录更新而来。

Sequelize 与其他 ORM 生命周期事件的不同之处在于,除了与实例连接相关的事件外,Sequelize 还将提供围绕查询方法的钩子(例如,findAllfindOne)。以下是一个包含每个查询事件简要说明的列表:

  • beforeFind(options): 在 Sequelize 内部对选项进行任何转换之前发生

  • beforeFindAfterExpandIncludeAll(options): 在 Sequelize 扩展include属性(例如,为特定关联设置适当的默认值)后触发的事件

  • beforeFindAfterOptions(options): 在查询方法调用查询之前以及 Sequelize 完成填充/转换选项之后发生

  • afterFind(instances, options): 在查询方法完成后返回单个实例或实例数组

  • beforeCount(options): 在count()实例方法查询数据库之前触发此事件

现在我们对可以使用哪些钩子和生命周期执行的顺序有了更好的理解,我们可以开始构建带有附加生命周期事件的模型。

定义、删除和执行生命周期事件

有几种方法可以将生命周期事件附加到模型和 Sequelize 的行为上。这些方法中的每一种都允许我们通过引用传递来更改从钩子参数派生的属性值。例如,您可以通过在生命周期方法内部更新对象上的属性来简单地向afterFind返回的实例添加额外的属性。默认情况下,Sequelize 将生命周期事件视为同步操作,但如果您需要异步功能,则可以返回一个Promise对象或一个async函数。

定义实例和模型生命周期事件

实例和模型生命周期事件可以通过多种方式定义,包括将这些事件定义为本地钩子(直接从模型本身定义)。定义本地钩子有几种方法;我们将从在模型初始化期间声明钩子的基本示例开始:

class Receipt extends Model {}
Receipt.init({
  subtotal: DataTypes.DECIMAL(7, 2)
}, {
  hooks: {
    beforeValidate: (receipt, options) => {
      if (isEmployee(receipt.customer)) {
        receipt.subtotal = 0;
      }
    }
  }
});
// or with the define() method
sequelize.define('Receipts', {
  subtotal: DataTypes.DECIMAL(7, 2)
}, {
  hooks: {
    beforeValidate(receipt, options) => { … })
  }
});

要在初始化之外定义完全相同的钩子,我们可以使用 addHook() 方法或直接调用相应的生命周期方法。此方法为插件和适配器在定义模型后轻松集成提供了方便。以下是如何使用此方法的简单示例:

function employeeDiscount(receipt, options) {
  if (isEmployee(receipt.customer)) {
    receipt.subtotal = 0;
  }
}
class Receipt extends Model {}
Receipt.init({ subtotal: DataTypes.DECIMAL(7, 2) });
Receipt.addHook('beforeValidate',employeeDiscount);
// or you can use the direct method:
Receipt.beforeValidate(employeeDiscount);

之前的例子提供了同步事件的说明。异步钩子的一个例子是返回一个 Promise(如前所述),如下所示:

async function employeeDiscount(receipt, options) {
  if (!customerIsEmployee) {
    return;
  }
  const discountTotal = await 
  getDiscountFromExternalAccountingService(employeeId);
  receipt.subtotal = discountTotal;
}
Receipt.addHook('beforeValidate', employeeDiscount);
// or…
Receipt.beforeValidate(employeeDiscount);

要从同步生命周期事件中抛出错误,你可以返回一个被拒绝的 Promise 对象:

Receipt.beforeValidate((receipts, options) => {
  return Promise.reject(new Error("Invalid receipt"));
});

为了组织目的,你可以使用 addHook() 或直接方法为你的生命周期事件声明名称:

Receipt.addHook('beforeValidate', 'checkForNegativeSubtotal', (receipt, options) => { … });
// or
Receipt.beforeValidate('checkForNegativeSubtotal', (receipt, options) => {…});

这些例子为我们提供了在模型本身的局部作用域上分配生命周期事件的方法。如果我们想在全局作用域(适用于所有模型)上定义生命周期事件,我们将使用 Sequelize 构造函数来完成:

const sequelize = new Sequelize(…, {
  define: {
    hooks: {
      beforeValidate() {
     // perform some kind of data transformation/validation
      }
  }
});

这将为未定义自己的 beforeValidate 钩子的模型生成默认的 beforeValidate 钩子。如果你希望无论模型是否有自己的定义都运行全局钩子,我们可以定义永久钩子

sequelize.addHook('beforeValidate', () => { … });

即使模型有自己的 beforeValidate 钩子定义,Sequelize 仍然会执行全局钩子。如果我们有一个与同一生命周期事件关联的全局和局部钩子,那么 Sequelize 会首先执行局部钩子(们),然后是全局钩子(们)。

对于特定的模型事件类型(如 bulkDestroybulkUpdate),Sequelize 默认不会按行执行单个删除和更新钩子。要修改这种行为,我们可以在调用这些方法时添加 { individualHooks: true } 选项,如下所示:

await Receipt.destroy({
  where: { … },
  individualHooks: true
});

注意

使用 { indvidualHooks: true } 选项可能会降低性能,这取决于 Sequelize 是否需要检索行、在内存中存储行/附加信息(例如,bulkDestroybulkUpdate 但不是 bulkCreate),以及为每条记录执行单个钩子。

移除生命周期事件

一些项目可能需要条件性地调用生命周期事件。例如,我们可能需要进行某种验证来检查用户是否仍然有资格回复论坛上的评论。这种验证对于生产环境是合适的,但对于开发环境则不是必需的。

一种方法是在钩子定义周围创建条件逻辑——例如,以下内容:

if (!isDev) {
  User.addHook('beforeValidate', 'checkForPermissions', …);
}

这在技术上是可以工作的,但如果我们有多个规定,比如在 afterCreate 钩子中发送订单邮件或在生产环境中仅退款?我们将在代码库中有很多“if”语句。Sequelize 提供了一种移除生命周期事件的方法来帮助组织这种类型的工作流程,称为 removeHook

我们可以像平时一样加载所有生命周期事件,但如果我们的环境处于开发阶段,那么我们可以遍历所有模型并移除适用的钩子。所有这些细粒度的调整都可以通过removeHook方法组织在一个函数中:

function removeProductionOnlyHooks() {
  // this will remove all matching hooks by event type and 
     name
  User.removeHook('beforeValidate', 'checkForPermissions');
  // this will remove all beforeValidate hooks on the User 
     model
  User.removeHook('beforeValidate');
  // this will remove all of the User model's hooks
  User.removeHook();
}
 // load our models…
if (isDev) {
  removeProductionHooksOnly();
}

移除生命周期事件对于应用程序中的定时行为或移除显式调试钩子很有用。下一节将帮助我们了解执行生命周期事件时的操作顺序以及特定生命周期事件将在何时执行。

执行生命周期事件

Sequelize 将根据你调用的方法运行相应的/适用的生命周期事件。使用我们之前的Transactions模型示例,如果我们运行Transactions.create({ … }),那么 Sequelize 将自动运行以下生命周期事件(按顺序):

  1. beforeValidate

  2. afterValidate/validationFailed

  3. beforeCreate

  4. beforeSave

  5. afterSave

  6. afterCreate

需要注意的一个问题是,在执行生命周期事件时,当你使用update()方法时,重要的是要记住,除非某个属性的值已更改,否则 Sequelize 不会执行生命周期事件。

例如,这不会调用相应的生命周期事件:

var post = await Post.findOne();
await Post.update(post.dataValues, {
  where: { id: post.id }
});

由于值没有改变,Sequelize 将忽略生命周期事件。如果我们想强制这种行为,我们可以在更新的配置中添加一个hooks: true参数:

await Post.update(post.dataValues, {
  where: { id: post.id },
  hooks: true
});

现在我们已经了解了如何定义、移除和执行生命周期事件的基础知识,我们可以继续探讨如何利用关联和事务的钩子。

使用关联和事务的生命周期事件

作为默认行为,Sequelize 将在不将事务与生命周期范围内的任何数据库查询相关联的情况下执行生命周期事件。然而,有时我们的项目需要在生命周期事件中使用事务,例如会计的账簿或创建日志条目。当调用某些方法,如updatecreatedestroyfindAll时,Sequelize 提供了一个transaction参数,这将允许我们使用在生命周期范围外定义的事务在生命周期内部使用。

注意

当在模型上调用beforeDestroyafterDestroy时,Sequelize 会故意跳过与该模型关联的任何销毁操作,除非onDelete参数设置为CASCADEhooks参数设置为true。这是由于 Sequelize 需要逐行显式删除每个关联行,如果不小心可能会导致拥堵。

如果我们要编写一个简单的会计系统,并希望作为单独的账簿创建日志条目,我们首先定义我们的模型如下:

class Account extends Model {}
Account.init({
    name: {
        type: DataTypes.STRING,
        primaryKey: true,
    },
    balance: DataTypes.DECIMAL,
});
class Book extends Model {}
Book.init({
    from: DataTypes.STRING,
    to: DataTypes.STRING,
    amount: DataTypes.DECIMAL,
});

然后,我们可以添加我们的Ledger模型,这是一个Book模型的副本,包含一个简单的引用列(为了简洁)和一个签名列,以指示交易已被外部来源批准:

class Ledger extends Model {}
Ledger.init({
    bookId: DataTypes.INTEGER,
    signature: DataTypes.STRING,
    amount: DataTypes.DECIMAL,
    from: DataTypes.STRING,
    to: DataTypes.STRING,
});

为了自动化Ledger工作流程,我们可以在 Book 模型中添加一个afterCreate钩子来记录账户余额的变化:

Book.addHook('afterCreate', async (book, options) => {
    const from = await Account.findOne(book.from);
    const to = await Account.findOne(book.to);
    // pretend that we have an external service that "signs" 
       our transactions
    const signature = await getSignatureFromOracle(book);
    await Ledger.create({
        transactionId: book.id,
        signature: signature,
        amount: book.amount,
        from: from.name,
        to: to.name,
    });
});

现在,当我们创建一个新的预订条目时,我们可以传递一个transaction引用,这样 Sequelize 就可以在同一个事务范围内执行生命周期范围内的查询。我们将在第六章中更深入地介绍事务,使用 Sequelize 实现事务,但到目前为止,我们将给出一个简单的示例,说明事务将是什么样子:

const Sequelize = require('@sequelize/core');
const sequelize = new Sequelize('db', 'username',  
                                'password');
await sequelize.transaction(async t => {
    // validate our balances here and some other work…

    await Book.create({
        to: 'Joe',
        from: 'Bob',
        amount: 20.21,
    }, {
        transaction: t,
    });
   // double check our new balances
   await checkBalances(t, 'Joe', 'Bob', 20.21);
});

在生命周期事件中使用事务的好处是,如果事务工作流程的任何部分执行失败,我们可以停止其余的工作流程,而不会稀释我们数据库记录的质量。在没有设置transaction参数的前一个示例中,即使checkBalances方法返回错误且未提交事务,Sequelize 仍然会创建一个账簿条目。

注意

Sequelize 有时会为findOrCreate等方法使用其自身的内部事务。您始终可以用您自己的事务覆盖此参数。

现在我们已经掌握了向我们的模型添加生命周期事件的基础,我们可以开始更新我们的 Avalon Airlines 项目。

将所有这些放在一起

对于本节,我们只需要更新BoardingTicket模型(位于models/boardingticket.js),添加两个属性costisEmployee,以及我们登机座位工作流程的一些生命周期事件。让我们看看步骤:

  1. 首先,我们需要在init方法中添加我们的属性,最终应该看起来像这样:

      BoardingTicket.init({
        seat: {
          type: DataTypes.STRING,
          validate: {
            notEmpty: {
       msg: 'Please enter in a valid seating arrangement'
            }
          }
        },
        cost: {
          type: DataTypes.DECIMAL(7, 2)
        },
        isEmployee: {
          type: DataTypes.VIRTUAL,
          async get() {
            const customer = await this.getCustomer();
    if (!customer || !customer.email) 
                 return false;
       return customer.email.endsWith('avalonairlines');
          }
        }
      }, {
        sequelize,
        modelName: 'BoardingTicket'
      });
    
  2. init函数下方,我们希望添加我们的生命周期事件。第一个将检查票是否被认为是员工票,如果是,则将小计标记为零:

      // Employees should be able to fly for free
      BoardingTicket.beforeValidate('checkEmployee', 
                                    (ticket, options) => {
        if (ticket.isEmployee) {
           ticket.subtotal = 0;
        }
      });
    
  3. 接下来,我们希望确保小计永远不会小于零(beforeValidate事件也适用于此处):

      // Subtotal should never be less than zero
      BoardingTicket.beforeSave('checkSubtotal', (ticket, options) => {
        if (ticket.subtotal < 0) {
          throw new Error('Invalid subtotal for this ticket.');
        }
      });
    
  4. 对于我们模型的最后一个生命周期事件,我们希望检查客户是否选择了被认为是可用的座位:

      // Ensure that the seat the customer has requested 
         is available
      BoardingTicket.beforeSave('checkSeat', async (tick
                                 et, options) => {
      // getDataValue will retrieve the new value (as 
         opposed to the previous/current value)
        const newSeat = ticket.getDataValue('seat');
        if (ticket.changed('seat')) {
          const boardingTicketExists = 
          BoardingTick-et.findOne({
            where: { seat: newSeat }
          });
          if (boardingTicketExists) {
            throw new Error(`The seat ${newSeat} has 
            al-ready been taken.`)
          }
        }
      });
    
  5. 这些更改之后,每次我们创建一个新的登机牌,我们的应用程序现在在保存记录之前将执行三个生命周期事件。仅作参考,以下是我们如何将事务传递给BoardingTicket模型的示例:

    await sequelize.transaction(async t => {
      await BookingTicket.create({
        seat: 'A1',
        cost: 12,
        customerId: 1,
      }, {
        transaction: t,
      });
    });
    

这就完成了本章对 Avalon Airlines 项目的必要更改。我们添加了一个检查小计和座位可用性的生命周期事件。我们还通过一个示例展示了如何将事务传递给特定的查询,我们将在下一章中进一步展开。

摘要

在本章中,我们介绍了生命周期事件是什么,以及如何在日常应用中使用它,Sequelize 提供了哪些生命周期事件以及它们的启动顺序,以及如何向 Sequelize 模型添加或删除生命周期事件。

在下一章中,我们将介绍事务的工作原理,它们的使用方式以及如何在 Sequelize 中进行配置。此外,下一章还将涵盖事务的不同锁定类型以及受管理和非受管理事务之间的区别。

参考文献

如果你在生命周期事件方面遇到问题,可以在此处找到快速参考:sequelize.org/master/manual/hooks.xhtml

第六章:使用 Sequelize 实现事务

在前面的章节中,我们介绍了如何使用生命周期事件、验证和约束来确保从我们的 Node.js 应用程序内部维护数据完整性。然而,这些方法并不能保证数据库内部数据的一致性。数据库提供了一种使用事务来原子化完整性的方法。

事务用于确保一个过程在没有中断(如连接失败或电源突然断电)的情况下完成。它们还用于隔离或锁定应用程序,防止并发地操作数据,从而减轻竞争条件问题。事务通过遵循ACID原则来保证数据的有效性,其中ACID代表原子性(“全有或全无”行为)、一致性(遵守约束)、隔离性(事务按顺序发生,彼此之间不知情)和持久性(持久存储)。

事务的一个通用用例是从一个用户账户向另一个用户账户转账。如果用户 A账户中有 30 个硬币,并且在用户 A决定从另一个用户那里购买价值 15 个硬币的商品之前,用户 B用户 A收取了 20 个硬币,那么数据库应该防止用户 A因为余额不足而无法购买价值 15 个硬币的商品。数据库会看到用户 A在收取 15 个硬币之前已经被收取了 20 个硬币,然后会对第二个事务发出回滚

注意

事务还提供了一种称为保存点的功能,它作为“数据库时间快照”,记录事务本身所做的更改,这对于多步事务非常有用。例如,在银行场景中,我们只交易货币本身,但在供应商的商店中,我们必须确保商品和货币处于适当的位置。

默认情况下,Sequelize 不会在事务下执行查询,但它确实提供了两种与事务交互的方法,分别称为管理事务和非管理事务。管理事务将自动/隐式地提交更改或回滚更改,具体取决于是否存在错误。非管理事务依赖于开发者调用适当的提交或回滚更改的方法。

在本章中,我们将涵盖以下内容:

  • 深入探讨并举例说明管理事务和非管理事务

  • 使用延续本地存储CLS)进行部分事务

  • 管理和配置高级事务选项,包括隔离级别

  • 使用生命周期事件和锁与事务一起使用

具体来说,我们将探讨以下主题:

  • 管理事务和非管理事务

  • 并发运行事务

  • 隔离级别和高级配置

  • 将所有内容整合在一起

注意

您可以始终参考 Sequelize 的代码库,以维护这里可用的交易方法列表的最新状态:

github.com/sequelize/sequelize/blob/v6/src/transaction.js

技术要求

您可以在github.com/PacktPublishing/Supercharging-Node.js-Application-with-Sequelize/blob/main/ch6找到本章的代码文件。

管理事务和非管理事务

对于有先前对象关系映射ORM)经验的开发者来说,管理事务通常更容易,而对于直接编写结构化查询语言SQL)的开发者来说,非管理事务可能更为熟悉。非管理事务的设计是显式的,但管理事务在状态管理方面有一些隐式行为,例如自动创建事务实例并使用该事务调用您的回调方法。

让我们看看创建非管理事务的步骤,如下所示:

  1. 我们首先创建一个事务实例,如下所示:

    const tx = await sequelize.transaction();
    
  2. 接下来,我们将在 try 块中包装我们的查询。在这个例子中,我们将使用相同的事务实例增加和减少两个账户余额各 100,如下所示:

    try {
        const amount = 100;
        await Account.increment(
            { balance: amount * -1 },
            {
                where: { id: 1 },
                transaction: tx
            }
        );
        await Account.increment(
            { balance: amount },
            {
                where: { id: 2 },
                transaction: tx
            }
        );
    

以下代码行将在前两个查询成功执行后提交我们的事务:

await tx.commit();
  1. 现在,我们可以关闭 try 块并添加一个 catch 块来处理事务中抛出的任何错误,如下所示:

    } catch (error) {
      await tx.rollback();
      // log the error here
    }
    

tx.rollback() 命令会告诉数据库撤销此事务内所做的任何更改。无论是否有条件语句或错误处理,您都可以在任何时候回滚事务。

通过使用 管理事务,Sequelize 可以为您自动化很多这项工作。假设我们的 Account 模型有一个约束,即余额必须大于零,并且发送账户的余额中只有五枚硬币。我们可以将先前的非管理事务示例重写为管理事务,如下所示:

try {
    const amount = 100;
    await sequelize.transaction(async (tx) => {
        await Account.increment(
            { balance: amount },
            {
                where: { id: 1 },
                transaction: tx
            }
        );

        await Account.increment(
            { balance: amount * -1 },
            {
                where: { id: 2 },
                transaction: tx
            }
        );
    });

    // the transaction has automatically been committed
} catch (error) {
    // Sequelize has already rolled back the transaction 
       from the try block
}

管理事务将根据是否有从适用查询抛出异常自动提交或回滚。您仍然可以在管理事务中通过在事务块内抛出错误来手动回滚,如下所示:

try {
    await sequelize.transaction(async (tx) => {
        // some queries
        throw new Error("rolling back the transaction manu
                         ally here");       
        // some more queries
    });
} catch (error) {
    // rolling back the transaction manually here
}

有时,我们的应用程序可能需要运行不同的并发事务。我们可以递归地链式多个事务,或者使用名为 CLS 的模块。在下一节中,我们将介绍如何使用这两种方法使用并发事务。

并行运行事务

根据您的应用程序是否需要在数据库中读取和写入之间进行隔离,您可能需要显式地同时运行多个事务。Sequelize 提供了两种并行运行事务的方法:递归链式事务(Sequelize 的原生方法)或通过第三方模块 CLS 集成您的应用程序。

注意

SQLite 不支持同时运行多个事务。

使用 Sequelize 同时运行事务

我们可以通过链式调用两个事务方法来使用 Sequelize 同时运行事务,如下所示:

sequelize.transaction((tx1) => {
    return sequelize.transaction((tx2) => {

现在,我们可以在使用不同事务的同时同时运行多个查询,如下所示:

        return Promise.all([
            Account.create({ id: 1 }, { transaction: null }),
            Account.create({ id: 2 }, { transaction: tx1 }),
            Account.create({ id: 3 }, { transaction: tx2 }),
        ]);
    });
});

默认情况下,Sequelize 不会使用事务实例变量。如果我们省略了最后一个Account.create命令的{ transaction: tx2 }选项,那么 Sequelize 将不会使用事务实例变量,其行为将类似于第一个Account.create命令,即{ transaction: null }

使用 CLS 运行事务

使用 CLS 与 Sequelize 结合可以帮助您自动将事务传递给所有查询,并提供类似线程局部存储的功能。CLS 自动执行事务的优势在于,某些数据库连接池驱动程序要求我们在网络上提交事务,这手动管理起来会非常繁琐。线程局部存储使我们能够在应用程序的不同部分创建的事务之间共享上下文。

注意

要了解更多关于 CLS 的信息,请参考项目的 Git 仓库:github.com/othiym23/node-continuation-local-storage

本书没有将 CLS 与 Sequelize 集成,但为了完整性,我们将介绍如何为您的项目启用 CLS。我们需要安装必要的包,如下所示:

npm install continuation-local-storage

然后,我们可以初始化一个 CLS 命名空间,如下所示:

const cls = require('continuation-local-storage');
const namespace = cls.createNamespace('custom-sequelize-namespace');

然后,我们可以将namespace变量传递给 Sequelize 构造函数的useCLS方法,如下所示:

const Sequelize = require('@sequelize/core');
Sequelize.useCLS(namespace);
const sequelize = new Sequelize(/* … */);

由于我们使用 Sequelize 构造函数,所有 Sequelize 的实例都将共享相同的命名空间。目前 Sequelize 不支持单独的 CLS 实例。

从先前的示例中借鉴,我们在查询中省略了事务参数的分类,如下例所示:

sequelize.transaction((tx1) => {
    return sequelize.transaction((tx2) => {
        return Promise.all([
            Account.create({ id: 1 }, { transaction: null }),
            Account.create({ id: 2 }, { transaction: tx1 }),
            Account.create({ id: 3 }),
        ]);
    });
});

使用 CLS,Sequelize 将传递最内层作用域的事务实例变量,在先前的例子中将是tx2。如果我们省略第一个Account.create命令的{ transaction: null }选项,那么 Sequelize 将默认使用tx2作为其事务,就像在最后的Account.create命令中一样。第二个——中间的——Account.create命令仍然会显式使用tx1事务实例。

由于 Sequelize 自动将事务传递给查询,以下两个示例将使用相同的tx实例变量执行:

await sequelize.transaction(async () => { // the tx
argument is not required
        await removeUserInventory(id);
        await User.destroy({ where: { id } }); // tx is also 
        used here
});
async function removeUserInventory(id) {
    // this query will also use the same scope tx variable 
       as User.destroy
    await UserInventory.destroy({ where: { userId: id } });
}

如您所见,启用 CLS 与 Sequelize 结合使用可以提供一些优势,并更好地组织项目的代码库。

注意

要获得 CLS 的更多功能,请参考名为 CLS-Hooked 的项目:

github.com/jeff-lewis/cls-hooked

隔离级别和高级配置

在本节中,我们将介绍 Sequelize 为每种类型的事务提供的不同隔离级别和配置选项。对于管理事务,方法签名如下:sequelize.transaction(options, callback)。非管理事务的签名是 sequelize.transaction(options)

下面是两种事务类型可配置选项的列表:

  • type—SQLite 中的一个选项,用于设置事务类型。可能的值有 DEFERRED(默认值)、IMMEDIATEEXCLUSIVE。有关更多信息,请参阅 www.sqlite.org/lang_transaction.xhtml

  • isolationLevel—设置事务的隔离级别。以下说明是在 MySQL 的上下文中,但应适用于其他数据库。READ_UNCOMMITTED—使用非锁定机制读取数据。这可能导致并发问题,使用已回滚的其他事务中的过时或无效数据。

  • READ_COMMITTED—即使在同一事务内部也执行一致的读取。换句话说,读取数据将与同一事务内部先前查询执行的更新保持一致。

  • REPEATABLE_READ—在读取信息方面类似于 READ_COMMITTED,但在 MySQL 的 InnoDB 数据库引擎方面有一些具体规定。有关如何一致性地锁定记录的更多信息,请参阅 dev.mysql.com/doc/refman/5.7/en/innodb-transaction-isolation-levels.xhtml#isolevel_repeatable-read

  • SERIALIZABLE—比 REPEATABLE_READ 更严格的规则集。此隔离级别通常用于解决数据库的并发和死锁相关的问题。

  • deferrable—仅适用于 PostgreSQL,此设置确定约束是否可以延迟或立即检查。* logging—一个函数,Sequelize 将传递查询及其参数作为参数。

Sequelize 提供了用于设置隔离级别的常量变量,以便于使用,如下所示:

const Sequelize = require('@sequelize/core');
sequelize.transaction({
    isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.
                    SERIALIZABLE
}, (tx) => { /* ... */ });

我们还可以通过在初始化 Sequelize 时设置 isolationLevel 选项,在实例级别上设置事务的隔离级别,如下所示:

new Sequelize('db', 'user', 'pw', {
    isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.
                    READ_COMMITTED
});

现在,任何后续的事务都将默认使用 READ_COMMITTED 级别。这些隔离级别与读取数据相关。在下一节中,我们将介绍写入数据库的锁定机制。

使用 Sequelize 锁定行

有时,我们的应用程序需要我们在执行事务时暂时锁定信息,并防止其他事务提交到同一表或行。数据库从业者可能知道这种机制称为 SELECT FOR UPDATE 查询。您可以在以下代码片段中看到一个此类查询的示例:

sequelize.transaction((tx) => {
    const seat = Seats.findOne({
        where: { venue: 1, row: 5, seat: 13 }
        transaction: tx,
        lock: true
    });
    // ... more queries ...
});

假设每个rowseat实例在每个venue实例中都是唯一的,前面的示例将锁定该特定座位的记录,直到事务提交或回滚。

假设我们想要检索一个不在挂起交易中的座位列表。如果我们的数据库管理系统支持SKIP LOCKED命令,那么在查询数据时可以使用skipLocked: true配置选项。为了演示SKIP LOCKED功能,我们可以首先为特定座位添加一个锁定记录,如下所示:

const tx1 = await sequelize.transaction();
const seat = Seats.findOne({
    where: { venue: 1, row: 5, seat: 13 }
    transaction: tx1,
    lock: true
});

接下来,以下查询将使用SKIP LOCKED规则并返回任何由其他挂起交易(在本例中为行 5,座位 13)未锁定的适用行:

const tx2 = await sequelize.transaction();
const seats = Seats.findAll({
    where: { venue: 1 }
    transaction: tx2,
    lock: true,
    skipLocked: true
});

注意

MySQL 从 8.0.1 版本开始支持SKIP LOCKED。本书的代码库不需要skipLocked,但如果您使用的是较旧版本并尝试使用skipLocked,那么 Sequelize 将默默地从查询中省略SKIP LOCKED命令,可能会导致意外的行为或结果。

使用生命周期事件进行事务

Sequelize 目前只为事务提供一种生命周期——即使是事务——明确地。这个名为afterCommit的生命周期事件可以用于管理和非管理事务。如果事务回滚,则不会触发此事件,并且该事件不能修改其事务对象(与传统生命周期事件不同)。

要调用afterCommit钩子,我们可以将事件添加到事务实例中,如下所示:

sequelize.transaction((tx) => {
    tx.afterCommit((trx) => {
        // … your logic here ...
    });
    // ... queries using tx ...
});

我们可以通过afterSave事件将afterCommit事件附加到整个模型上。使用afterCommit的一个好例子是将序列化数据发送到其他服务、应用程序、区块链数据库等。以下是如何使用afterCommit的示例:

Seats.afterSave((instance, options) => {
    if (options.transaction) {
        // appending afterCommit to the transaction instance here
        options.transaction.afterCommit(() => { /* your logic here */ });
        return;
    }
    // code will continue here if we did not save under a transaction
});

到目前为止,我们已经介绍了 Sequelize 提供的不同类型的事务,事务的命名空间环境,隔离级别,锁定和生命周期事件。通过这些技能的组合,我们最终可以开始将一些知识应用到我们的应用程序中。

整合所有内容

现在我们已经了解了使用 Sequelize 进行事务的核心原则,我们可以开始向我们的Avalon Airlines项目添加内容。我们的业务伙伴刚刚通知我们,投资者想要一个预订航班而不处理支付的简单演示。为此任务,我们需要添加几个新文件,更新BoardingTicketFlightSchedule模型,向我们的 Express 应用程序添加新路由,并安装一个新的 Node.js 包。

首先,让我们开始添加项目所需的新 Node.js 包。这个包被称为 Luxon (moment.github.io/luxon/),它是一个日期和时间 JavaScript 库。使用以下命令添加包:

npm i --save luxon

接下来,我们将想要修改位于models/boardingticket.js中的BoardingTicket模型中的一个生命周期事件,通过添加/更改以下突出显示的代码:

BoardingTicket.beforeSave('checkSeat', async (ticket, options) => {
    const newSeat = ticket.getDataValue('seat');
    const { transaction } = options;
    if (ticket.changed('seat')) {
      const boardingTicketExists = await BoardingTicket.findOne({
        where: {
          seat: newSeat
        },
        transaction,
      });
      if (boardingTicketExists !== null) {
        throw new Error(`The seat ${newSeat} has already been taken.`);
      }
    }
  });

我们还需要更新另一个模型,即位于models/flightschedule.js中的FlightSchedule模型。在文件顶部添加以下代码行:

const { DateTime } = require('luxon');

然后,在validDestination方法下方添加另一个验证到validate对象中,如下所示:

      validateDepartureTime() {
        const dt = DateTime.fromJSDate(this.departureTime);
        if (!dt.isValid) {
          throw new Error("Invalid departure time");
        }
        if (dt < DateTime.now()) {
          throw new Error("The departure time must be set 
                           within the future");
        }
      },

现在,我们可以在项目主目录中添加一个新文件夹和文件routes/flights.js,并添加以下代码行以加载适当的模块和文件:

const { DateTime } = require("luxon");
const models = require("../models");

对于我们第一个与航班相关的航线,我们需要找到一种方法来首先创建我们的飞机。查看models/airplane.js中的属性,我们可以确定我们需要一个型号名称和每架飞机的座位数。代码如下所示:

async function createAirplane(req, res) {
    const { name, seats } = req.body;

POST数据中,我们期望发送nameseats值到我们的 Express 应用程序。现在,我们可以添加飞机创建逻辑,以及关闭和导出函数,如下所示:

    try {
        const airplane = await models.Airplane.create({
            planeModel: name,
            totalSeats: seats,
        });
        return res.json(airplane);
    } catch (error) {
        res.status(500).send(error);
    }
}
exports.createAirplane = createAirplane;

我们下一个函数将是用于创建航班时刻表。我们需要airplaneIdorigindestinationdeparture POST值来创建航班时刻表,如下所示:

async function createSchedule(req, res) {
    const { airplaneId, origin, destination, departure } = 
    req.body;

首先,让我们验证并解析出发时间到一个本地的DateTime对象,如下所示:

        const dt = DateTime.fromISO(departure);
        if (!dt.isValid) {
            return res.status(400).send("invalid departure 
                                         time");
        }

接下来,我们将想要检查飞机是否实际存在,因此我们将执行以下代码来找出答案:

    try {
 const plane = await models.Airplane.findByPk(airplaneId);
        if (!plane) {
            return res.status(404).send("airplane does not 
                                         exist");
        }

如果飞机确实存在,我们将想要为它创建一个航班时刻表。我们将创建过程封装在一个事务中,以确保创建时刻表以及将时刻表与特定的飞机关联不会产生错误。

对于这个特定的演示,事务不是必需的,但在实际应用中,我们想要确保根据航线和出发时间,飞机不会被过度预订。事务块看起来如下所示:

        const flight = await sequelize.transaction(async 
        (tx) => {
   const schedule = await models.FlightSchedule.create({
                originAirport: origin,
                destinationAirport: destination,
                departureTime: dt,
            }, { transaction: tx });

然后,我们设置相关的飞机,返回时刻表记录,完成事务,并使用JavaScript 对象表示法JSON)数据渲染响应,如下所示:

            await schedule.setAirplane(plane, 
            { transaction: tx });
            return schedule;
        });
       return res.json(flight);

文件的最后部分是捕获之前try块中的任何错误,并导出createSchedule函数,如下所示:

    } catch (error) {
        return res.status(500).send(error);
    }
}
exports.createSchedule = createSchedule;

现在,我们可以在routes/tickets.js中创建一个新的文件,该文件将作为预订实际航班的路由。出于演示目的,我们将省略确定价格和客户会话等复杂功能,并用常量值填充这些细节。创建文件后,我们将在文件顶部加载我们的模型,如下所示:

const models = require("../models");

对于我们的bookTicket方法,我们需要一个scheduleIdseat POST参数,以及为创建登机牌打开一个事务。以下是我们可以添加这些内容的示例:

async function bookTicket(req, res) {
    try {
        const { scheduleId, seat } = req.body;
        const t = await sequelize.transaction(async (tx) => {

通过执行以下代码检查FlightSchedule模型是否存在:

const schedule = await models.FlightSchedule.findByPk
(scheduleId, {transaction: tx});
            if (!schedule) {
                throw new Error("schedule could not be 
                                 found");
            }

让我们创建一个新的登机牌,如下所示:

const boardingTicket = await models.BoardingTicket.create({
                seat,
            }, { transaction: tx });

现在,我们将设置登机牌的关联,并在完成事务的同时返回登机牌,如下所示:

            // this is where we would set a customer if we had an application with authentication, etc.
            // await ticket.setCustomer(customerId, { transaction: tx });
            await schedule.addBoardingTicket(boardingTicket, { transaction: tx });

            return boardingTicket;
        });
        return res.json(t.toJSON());

捕获任何错误并导出函数,如下所示:

    } catch (error) {
        return res.status(400).send(error.toString());
    }
}
exports.bookTicket = bookTicket;

接下来,我们希望添加一个名为 body-parser 的模块,该模块有助于转换 Express 中的不同 req.body。更多信息,请访问 github.com/expressjs/body-parser。我们可以使用以下命令安装并将包添加到我们的 package.json 文件中:

npm i --save body-parser

我们最后要编辑的文件将是项目主目录中的 index.js 文件。我们希望在第一行引入 express 模块之后添加以下模块:

const bodyParser = require("body-parser");

const models = require("./models"); 行之下,我们希望添加我们的新导出函数。这是我们的做法:

const { bookTicket } = require("./routes/tickets")
const { createAirplane, createSchedule } = require("./routes/flights");

在第一个路由 app.get('/', …) 之上,添加以下代码行以支持 JSON POST

app.use(bodyParser.json({ type: 'application/json' }));

接下来,我们希望在 app.get('/airplanes/:id', ...) 行之上添加以下代码行以创建 createAirplane 路由:

app.post('/airplanes', createAirplane);

然后,我们可以在 app.listen(3000, ...) 行之上添加我们剩余的新路由,如下所示:

app.post('/schedules', createSchedule);
app.post('/book-flight', bookTicket);

由于我们所有的更改都已提交,我们现在可以通过执行以下命令来运行我们的应用程序:

npm run start

为了测试我们的应用程序,我们可以使用 cURL 或任何 HTTP REST 工具,如 Postman (www.postman.com/) 或 HTTPie (httpie.io/)。在创建飞行日程安排之前,让我们创建一架新的飞机,如下所示:

curl -X POST -H "Content-Type: application/json" -d "{\"name\": \"A320\", \"seats\": -1}" http://127.0.0.1:3000/airplanes

我们应该会看到类似以下响应:

{"name":"SequelizeValidationError","errors":[{"message":"A plane must have at least one seat","type":"Validation error","path":"totalSeats","value":-1,"origin":"FUNCTION","instance":{"id":null,"planeModel":"A320","totalSeats":-1,"updatedAt":"2022-02-21T16:27:18.336Z","createdAt":"2022-02-21T16:27:18.336Z"},"validatorKey":"min","validatorName":"min","validatorArgs":[1],"original":{"validatorName":"min","validatorArgs":[1]}}]}

这个特定的 A320 型号一次最多有 150 个座位可供客户使用。当我们调整可用的总座位数时,我们的新命令将如下所示:

curl -X POST -H "Content-Type: application/json" -d "{\"name\": \"A320\", \"seats\": 150}" http://127.0.0.1:3000/airplanes

之前的命令应该返回类似以下内容的响应:

{"id":1,"planeModel":"A320","totalSeats":150,"updatedAt":"2022-02-21T15:49:19.883Z","createdAt":"2022-02-21T15:49:19.883Z"}

在创建日程安排的下一个命令时,我们将想要记住 id 值:

curl -X POST -H "Content-Type: application/json" -d "{\"airplaneId\": 1, \"origin\": \"LAX\", \"destination\": \"ORD\", \"departure\": \"2060-01-01T14:00:00Z\"}"  http://127.0.0.1:3000/schedules

之前的请求应该导致类似以下内容的错误:

{"name":"SequelizeValidationError","errors":[{"message":"Invalid destination airport","type":"Validation error","path":"destinationAirport","value":"ORD","origin":"LAX","instance":{"id":null,"originAirport":"LAX","destinationAirport":"ORD","updatedAt":"2022-02-21T18:11:02.108Z","createdAt":"2022-02-21T18:11:02.108Z"},"validatorKey":"isIn","validatorName":"isIn","validatorArgs":[["MIA","JFK","LAX"]],"original":{"validatorName":"isIn","validatorArgs":[["MIA","JFK","LAX"]]}}]}

我们目前不飞往芝加哥的奥黑尔国际机场!新的目的地现在将是使用 MIA 代码的迈阿密,如下所示:

curl -X POST -H "Content-Type: application/json" -d "{\"airplaneId\": 1, \"origin\": \"LAX\", \"destination\": \"MIA\", \"departure\": \"2060-01-01T14:00:00Z\"}" http://127.0.0.1:3000/schedules

响应应该类似于以下内容:

{"id":1,"originAirport":"LAX","destinationAirport":"MIA","departureTime":"2060-01-01T14:00:00.000Z","updatedAt":"2022-02-21T18:34:46.049Z","createdAt":"2022-02-21T18:34:46.038Z","AirplaneId":1}

对于预订请求,我们需要之前响应的 id 值和座位分配,如下所示:

curl -X POST -H "Content-Type: application/json" -d "{\"scheduleId\": 1, \"seat\": \"1A\"}" http://127.0.0.1:3000/book-flight

响应将类似于以下内容:

{"isEmployee":{},"id":1,"seat":"1A","updatedAt":"2022-02-21T18:55:30.837Z","createdAt":"2022-02-21T18:55:30.837Z"}

如果我们重复执行之前的命令,会显示一个错误消息,提示我们座位已被占用,如下所示:

Error: The seat 1A has already been taken.

这就完成了我们对 Avalon Airlines 项目的更改。我们实现了一种创建飞机和新的飞行日程以及使用事务分配登机牌的方法。这应该完成我们下一次投资者会议的要求。

摘要

在本章中,我们通过使用 CLS 进行全局作用域事务、支持的隔离级别、适用的生命周期事件和锁定事务,介绍了托管事务和无托管事务之间的区别。

在下一章中,我们将介绍如何直接从 Sequelize 处理自定义的 JSON 和 二进制大对象 (BLOB) 数据到数据库管理系统(DBMS)。下一章还将包含完成 Avalon Airlines 项目的高级指导。

第七章:处理自定义、JSON 和 Blob 数据类型

一些数据库管理系统提供了一种存储特定列类型(如 JSON 和 Blob 相关数据)的方法。这些列类型对于快速原型设计、处理无模式数据和发送接收缓冲数据非常有用。

通常,应用程序会使用NoSQL数据库,如 MongoDB,来处理和查询 JSON 文档,但这带来了一系列自己的问题。如果没有详尽的验证列表,我们就不能再坚持某种形式的规范化结构,NoSQL 数据库也无法执行事务或提供 ACID 兼容的功能。

注意

一些 NoSQL 数据库声称提供 ACID 兼容性,但它们通常附带一些规定和限制,例如单次事务中可以更新的最大文档数,或者事务不能超过某个时间窗口;否则,您将失去 NoSQL 数据库相对于 SQL 数据库的所有性能优势。

JSON 和 Blob 列数据类型有几种用例。使用 JSON,您可以存储一组非确定性的值记录集,这对于创建交易收据和审计系统等用例非常有用。Blob 列数据类型可以存储任何有助于集中检索和插入的文件,但内部,DBMS 可能会对该文件进行分片或分发。

通常,由于失去外部访问控制列表、阻塞写前日志文件以及存储这些文件时的虚假安全感,我们不推荐在 DBMS 中存储文件。我们还可能遇到页面大小增加的情况,这将增加检索记录所需的时间。一般来说,对于快速原型设计处理文件,使用 DBMS 是可以的,但不适用于生产环境。

注意

使用 JSON 列类型进行审计的一个例子是 PGAudit 的 Postgres 扩展。此扩展将转换之前的和新的记录集为 JSON 数据类型以存储不同的值。您可以参考www.pgaudit.org/了解更多关于其工作原理的信息。

Sequelize 能够处理所有支持 DBMS 的自定义和 Blob 类型,以及 SQLite、MySQL、MariaDB 和 PostgreSQL 的 JSON 列类型。对于 MSSQL 有一个解决方案,将在本章的与 JSON 一起工作部分详细解释。

在本章中,我们将涵盖以下内容:

  • 查询 JSON 和 JSONB 数据

  • 使用BLOB列类型

  • 创建自定义数据类型

技术要求

您可以在github.com/PacktPublishing/Supercharging-Node.js-Application-with-Sequelize/blob/main/ch7找到本章的代码文件。

查询 JSON 和 JSONB 数据

如前所述,JSON 列类型仅适用于 SQLite、MySQL、MariaDB 和 PostgreSQL。JSONB 列类型仅支持 PostgreSQL 数据库管理系统。这两种列类型之间的区别在于,JSONB 将在内部存储与 JSON 文档中的字段相关的附加信息。这将增加磁盘空间的需求,但将有助于使数据查询更快。

对于本节,假设我们在应用程序中有一个以下模型:

class Receipts extends Model {}
Receipts.init({
  receipt: DataTypes.JSON
});

现在,我们可以创建我们的文档:

await Receipts.create({
    receipt: {
        name: {
            first: "Bob",
            last: "Smith"
        },
        items: [
            {
                sku: "abc123",
                quantity: 10
            },
            {
                sku: "xyz321",
                quantity: 1
            }
        ],
        subtotal: 100
    }
});

我们现在可以使用传统的 Sequelize 方法查询我们的文档:

await Receipts.findOne({
    where: {
        receipt: {
            name: {
                first: "Bob",
                last: "Smith"
            }
        }
    }
});

或者我们可以使用特殊的点符号风格:

await Receipts.findOne({
    where: {
        "receipts.name.first": "Bob",
        "receipts.name.last": "Smith"
    }
});

点符号方法也适用于其他查找属性,例如 order

const receipts = await Receipts.findAll({
    where: {
      receipt: {
        name: {
          last: "Smith",
        },
      },
    },
    order: [
      ["receipt.name.first"]
    ]
});

在更新记录时,我们必须像传统的 NoSQL 文档存储系统一样重新插入整个文档:

await Receipts.update({
    receipt: {
        name: {
            first: "Bob",
            last: "Smith"
        },
        items: [
            {
                sku: "abc123",
                quantity: 10
            },
            {
                sku: "xyz321",
                quantity: 1
            }
        ],
        subtotal: 120
    }
  }, {
    where: {
      "receipt.name.first": "Bob"
    }
});

如果我们想要查询数组中的值,我们可能需要使用 Sequelize.literal 函数,如果我们的数据库管理系统没有本地支持 Op.contains 操作符(仅 PostgreSQL)。以下是一个使用 PostgreSQL 的 @> 操作符查询数组值的示例:

const receipts = await Receipts.findAll({
    where: {
        receipt: {
            items: {
                [Op.contains]: {
                    sku: "abc123"
                }
            }
        }
    }
});

由于 MySQL 不支持 contains 操作符,上一个查询的等效操作如下:

const receipts = await Receipts.findAll({
    where: Sequelize.literal(`JSON_CONTAINS(JSON_EXTRACT
        (receipt, '$.items[*].sku', '"abc123"')`)
});

MSSQL 也可以执行 JSON 的基本操作。以下是一个使用 MSSQL 和 Sequelize 查询 JSON 数据的示例:

class Users extends Model {}
Users.init({
    metadata: DataTypes.STRING
});
await Users.create({
    metadata: JSON.stringify({
        first_name: "Bob",
        last_name: "Smith"
    })
});
await Users.findAll({
    where: sequelize.where(
        sequelize.fn('JSON_VALUE', sequelize.col('metadata'), '$.first_name'),
        'Bob'
    )
});

不幸的是,对于 MSSQL,要搜索嵌套数组需要交叉连接和更多超出本书范围的主题,例如 OpenJSON(可以在 docs.microsoft.com/en-us/sql/t-sql/functions/openjson-transact-sql?view=sql-server-ver15 中参考)。

使用 BLOB 列类型

有时,我们的应用程序将需要我们存储缓冲区或二进制数据到我们的系统中。以下是一个使用 Sequelize 创建和读取二进制数据的快速示例:

  1. 我们将从我们的定义开始:

    class Users extends Model {}
    Users.init({
        avatar: DataTypes.BLOB,
        keycode: DataTypes.BLOB
    });
    
  2. 接下来,我们可以插入我们的记录,如下所示:

    await Users.create({
        avatar: require("fs").readFileSync
                ("/some/path/avatar.jpg"),
        keycode: Buffer.from("secretpassword")
    });
    
  3. 要检索和使用缓冲数据,我们可以简单地使用查找方法,并直接使用 Node.js 的 fs 模块写入:

    const user = await Users.findOne({});
    require("fs").writeFileSync(
        "/some/path/to/write/avatar.jpg",
        user.avatar
    );
    

现在我们已经了解了所有内置的数据类型,我们现在可以开始创建我们自己的自定义数据类型。自定义数据类型也可以用于将几个验证组合在一起或创建几个规则集到一个数据类型中。

创建自定义数据类型

Sequelize 通过扩展 DataTypes.ABSTRACT 抽象类为我们提供了一种创建自定义类型的方法。这允许我们保持代码库更加有序和一致。假设我们的应用程序需要大量遵守自然数法则的列。一个快速演示如下:

class Stats extends Model {}
Stats.init({
    A: {
        type: Sequelize.INTEGER(11).UNSIGNED.ZEROFILL,
        validate: {
            min: 1
        }
    },
    B: {
        type: Sequelize.INTEGER(11).UNSIGNED.ZEROFILL,
        validate: {
            min: 1
        }
    },
    C: {
        type: Sequelize.INTEGER(11).UNSIGNED.ZEROFILL,
        validate: {
            min: 1
        }
    },
});

如果我们有数百个这样的列,写出这些列可能会很繁琐。解决这个问题的方法之一是创建我们自己的自定义属性。让我们看看步骤:

  1. 第一步是扩展 ABSTRACT 类:

    class NATURAL_NUMBER extends DataTypes.ABSTRACT {
    
  2. 然后,我们需要告诉 Sequelize 如何将此数据类型转换为列类型。我们可以在类中定义一个 toSql 方法来完成此操作:

        toSql() {
            return 'INTEGER(11) UNSIGNED ZEROFILL'
        }
    

这将告诉 Sequelize 我们想要一个零填充的无符号整数。

  1. 接下来,我们可以通过创建一个 validate 方法来强制执行验证规则:

        validate(value, options) {
            const isNumber = Number.isInteger(value);
            const isAboveZero = Number.parseInt(value) > 0;
    
            return isNumber && isAboveZero;
        }
    

Sequelize 将自动检查此属性类型的值是否为整数且大于零。

  1. 下一个步骤是可选的,但为了完整性,以下方法分别用于写入和从数据库读取:

        _stringify(value) {
          return value.toString();
        }
        static parse(value) {
          return Number.parseInt(value);
        }
    

_stringify 方法将在将值发送到您的数据库之前将其转换为字符串,而 parse 方法将转换从数据库返回的值。

  1. 现在,我们可以关闭我们的类并调用一些强制性的方法:

    }
    NATURAL_NUMBER.prototype.key = NATURAL_NUMBER.key = 'NATURAL_NUMBER';
    DataTypes.NATURAL_NUMBER = Sequelize.Utils.classToInvokable(NATURAL_NUMBER);
    

Sequelize 将通过映射出类中的 key 值来识别您的属性数据类型。下一行将添加您的自定义数据类型到 Sequelize 的 DataTypes 命名空间。classToInvokable 方法将简单地包装您的类构造函数并返回一个新实例,这样您在定义模型时就不必显式调用 new DataTypes.NATURAL_NUMBER()

  1. 现在,我们可以定义我们之前的模型,如下所示:

    class Stats extends Model {}
    Stats.init({
        A: DataTypes.NATURAL_NUMBER,
        B: DataTypes.NATURAL_NUMBER,
        C: DataTypes.NATURAL_NUMBER
    });
    

当我们去创建或更新时,我们的属性将遵守我们之前设置的规则。以下三个示例将返回验证错误,因为 C 列的值不是一个自然数:

await Stats.create({
    A: 100,
    B: 20,
    C: "NotANumber" // not an number
});
await Stats.create({
    A: 100,
    B: 20,
    C: 1.1 // not an integer
});
await Stats.create({
    A: 100,
    B: 20,
    C: -3 // not a natural number
});

当我们将 C 改为一个自然数(如下面的代码所示)时,我们的查询现在将成功创建记录:

await Stats.create({
    A: 100,
    B: 20,
    C: 10
}); // success!

到目前为止,我们已经介绍了如何使用 Sequelize 内置类处理 JSON 和 BLOB 数据类型。我们还通过扩展 Sequelize 的 ABSTRACT 数据类型类创建了自定义数据类型。现在,我们可以在我们的项目中开始使用这些数据类型。

现在我们对如何显式处理 JSON 数据类型有了更好的理解,我们可以在 Avalon Airlines 项目中使用该类型。

将所有这些放在一起

我们的商业伙伴刚刚通知我们,我们希望能够记录每个适用事件的交易收据。这可能包括登机牌、额外行李或额外的水瓶,这意味着我们的数据没有确定的结构。为此任务,我们需要生成一个新的模型 Receipts 并更新我们的 BoardingTicket 模型。以下是步骤:

  1. 首先,我们可以开始生成一个新的模型,名为 Receipts,用于存储交易事件:

    sequelize-cli model:generate --name Receipts --attributes receipt:json
    
  2. 然后,运行我们的迁移:

    sequelize db:migrate
    
  3. 接下来,我们希望在 models/boardingticket.js 中的 BoardingTicket 模型中添加另一个生命周期事件,方法是在 module.exports 块的末尾添加以下代码:

      BoardingTicket.afterSave('saveReceipt', 
          async(ticket, options) => {
        await sequelize.models.Receipts.create({
          receipt: ticket.get()
        }, {
          transaction: options.transaction
        });
      });
    

这就完成了我们对 Avalon Airlines 项目的更改。我们使用 JSON 实现了一个新的存储收据数据的模型,并在创建或更新 BoardingTicket 模型后添加了一个生命周期事件。这应该完成我们下一次投资者会议的要求。

摘要

在本章中,我们探讨了使用特定数据类型(如 JSON 和 BLOB)读取和写入属性的不同方法。我们还学习了如何通过扩展 ABSTRACT 类来创建自定义数据类型,以创建一个更易于维护的代码库。

在下一章中,我们将介绍如何监控和记录您应用程序的查询。下一章还将包含完成 Avalon 航空公司项目的进一步说明。

第三部分 – 高级查询、使用适配器和日志查询

在本部分中,您将了解如何监控和衡量您应用程序的性能指标。您将使用与 Sequelize 和日志查询集成的第三方应用程序。您还将学习如何将您的应用程序部署到云应用程序平台。

本部分包括以下章节:

  • 第八章记录和监控您的应用程序

  • 第九章使用和创建适配器

  • 第十章部署 Sequelize 应用

第八章:日志记录和监控您的应用程序

维护记录和指标在我们的开发周期中提供了许多优势。它们可以帮助我们提高应用程序的性能,在问题成为问题之前观察问题,并让我们深入了解应用程序的状态。日志记录和监控您的应用程序可以减少您开发(和调试)所需的时间,以及在整个项目过程中您所获得的头痛次数。日志记录是经常被忽视或最少考虑的事情,但它可能是在丢失一小时的正常运行时间或整个一天的正常运行时间之间做出差别的关键。

假设我们有一个应用程序,它只是将注册表单的详细信息插入到数据库表中。有一天,团队不小心将 first_name 列重命名为 firstname,现在没有新的记录被插入。使用日志记录,我们会看到类似“first_name 列不存在”类型的错误。这将帮助我们查看数据库的模式,并找出断开连接发生的地方(在这种情况下,我们删除下划线的错误)。

如果错误比这更复杂怎么办?我们的应用程序现在正在集群中运行,集群中的每个节点都只从其他节点接收独特的消息。偶尔,我们会注意到我们的表中缺少一些记录,而数据本身并没有明显的模式。使用日志机制,我们偶尔会看到“无法建立连接”的错误。我们可以双重检查我们的连接池管理(如果适用)或测试每个节点,看我们是否可以成功连接到数据库。在一个小型集群中,这不会是问题,但在一个大型系统中,这可能会变得繁琐且耗时。

为帮助管理更大集群中的应用程序提供一个解决方案,就是为您的应用程序的日志记录记录自定义(或添加)额外的上下文。元信息,如机器的标识符,可能有助于我们之前的例子。Sequelize 提供了一种使用 options.logging 参数自定义我们的日志记录的方法,并能够通过不同的方法调用更改日志记录行为。

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

  • 使用所有可用接口配置日志记录

  • 集成第三方日志记录应用程序,如 Pino 或 Bunyan

  • 使用 OpenTelemetry 收集 Sequelize 的指标和统计数据

技术要求

您可以在此章节的代码文件github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch8中找到

使用所有可用接口配置日志记录

Sequelize 为将日志集成到应用程序中提供了几个重载签名。默认行为是针对每个查询调用 console.log。以下是 Sequelize 将遵守的签名列表:

  • function (msg) {}

  • function (...msg) {}

  • true/false

  • msg => someLogger.debug(msg)

  • someLogger.debug.bind(someLogger)

如果我们想自定义 Sequelize 的日志记录行为,以下示例将快速介绍如何实现:

function customLog(msg) {
    // insert into db/logger app here
    // ...
    // and output to stdout
    console.log(msg);
}
const sequelize = new Sequelize('sqlite::memory:', {
    logging: customLog
});

除了 Sequelize 将 SQL 查询发送到我们的 customLog 函数外,我们还提供了一个辅助方法,用于在需要记录查询之外的其他信息时。Sequelize 实例提供了一个 log 方法,可以像下面这样调用:

sequelize.log('this will send our message to customLog as 
well');

如果您的 Sequelize 实例的 benchmark 参数设置为 true,那么 Sequelize 将在消息末尾添加查询完成的总耗时。使用我们之前的示例,日志条目可能看起来类似如下:

Executed (default): SELECT * FROM ...; Elapsed time: 136ms

有时,我们可能需要记录日志查询、查询对象、适用参数或其他任何形式的元数据。Sequelize 将识别这种支持的扩展模式:

function multiLog(...msgs) {
    msgs.forEach(function(msg) {
        console.log(msg);
    });
}
const sequelize = new Sequelize('sqlite::memory:', {
    logging: multiLog
});

我们现在可以调用 Sequelize 实例的 log 方法,该方法将参数发送到我们的 multiLog 函数,如下所示:

sequelize.log('error', 'custom error message', Date.now(), { id: 100 });

由于 multiLog 函数的行为,这将每个参数打印到其自己的换行符上。

日志参数还可以接受布尔值。true 值将合并为 Sequelize 的默认行为(console.log)。将值设置为 false 将完全禁用日志记录并取消任何日志调用。以下示例将阻止 Sequelize 记录查询:

const sequelize = new Sequelize('sqlite::memory:', {
    logging: false
});

注意

日志记录的 true 值被认为是过时的,并且不推荐省略日志记录值以实现默认行为或使用 console.log 作为参数的值。

Sequelize 还可以通过每个可查询方法的日志参数(例如,findAllupdatecreate)限制日志记录到特定的查询。例如,如果我们想禁用特定查询的日志记录,我们可以通过将以下查询的 logging 参数设置为 false 来实现:

sequelize.findAll({
  where: {
    id: 1
  }
}, {
  logging: false
});

注意

您还可以利用 Sequelize 对 debug NPM 包的使用来查看查询的日志输出。通过设置环境变量为 DEBUG=sequelize:sql*,您的终端应显示 Sequelize 执行的查询。

集成第三方日志应用,如 Pino 或 Bunyan

如果我们的应用程序已经使用第三方日志应用,Sequelize 可以提供对这些系统进行集成的支持。本节引用了两个日志应用,Pino 和 Bunyan,但任何日志库或框架都应与 Sequelize 兼容。

与 Pino 集成

Pino 是一个低开销的 Node.js 日志记录器,它还提供编辑、传输和异步支持。假设我们的项目在 node_modules 文件夹中安装了 Pino,我们可以简单地将其与 Sequelize 实例集成,如下所示:

const logger = require('pino')();
const sequelize = new Sequelize('sqlite::memory:', {
    logging: (msg) => logger.debug(msg)
});

现在,当我们手动调用 sequelize.log 或执行查询时,日志将被发送到 Pino 日志库。输出将类似于以下内容:

{"level":30,"time":1650118644700,"pid":5363,"hostname":"MacBook-Pro-4.local","msg":"Executing (default): SHOW INDEX FROM `Airplanes` FROM `airline`"}

关于 Pino 的更多信息,您可以参考项目的仓库github.com/pinojs/pino

与 Bunyan 集成

有时,日志框架需要在将其绑定到 Sequelize 之前进行中间步骤。Bunyan 框架就是这样一个例子。Bunyan 是一个专注于提供序列化和流方法的日志框架。集成此框架看起来类似于以下内容:

const bunyan = require('bunyan');
const logger = bunyan.createLogger({name: 'app'});
const sequelize = new Sequelize('sqlite::memory:', {
    logging: (msg) => logger.info(msg)
});

上述示例显示了 Bunyan 日志与 Sequelize 的输出:

{"name":"app","hostname":"MacBook-Pro-4.local","pid":6014,"level":30,"msg":"Executing (default): SHOW INDEX FROM `Airplanes` FROM `airline`","time":"2022-04-16T14:33:13.083Z","v":0}

关于 Bunyan 的更多信息,您可以参考项目的仓库github.com/trentm/node-bunyan

从 Pino 和 Bunyan 的示例中,我们可以看到添加日志框架已经解决了我们独特的机器标识符、错误发生的时间和紧急研究。通过查看日志,现在应该更容易筛选出在集群或应用程序中发生错误的任何地方。

我们现在可以完成在 Avalon Airlines 项目中集成日志框架的工作。从项目的根目录开始,我们需要安装必要的包:

npm i pino

models/index.js 中,查看以下行:

const Sequelize = require('sequelize/core');

使用常量导出下面的 Pino 框架:

const logger = require('pino')();

导出常量后,请查看此行:

const db = {};

在下面,我们可以将日志参数添加到 config 对象中,如下所示:

config.logging = (msg) => logger.info(msg);

现在,我们的应用程序支持使用 Pino 日志框架进行自定义日志。

使用 OpenTelemetry 收集 Sequelize 的指标和统计数据

OpenTelemetry 是一个用于收集、聚合和度量各种统计、指标、跟踪和日志的标准规范。OpenTelemetry 可以帮助我们识别瓶颈可能发生的位置,对日志进行分类和应用拓扑过滤器,并连接到第三方应用程序(例如,用于警报监控)。

要将 OpenTelemetry 与 Sequelize 集成,我们将在 Avalon Airlines 项目中安装以下包:

npm i @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/instrumentation @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node opentelemetry-instrumentation-sequelize

models/index.js 中,在 'use strict'; 行下方,我们现在可以添加我们的新包:

const { NodeTracerProvider } = 
    require(‹@opentelemetry/sdk-trace-node');
const { registerInstrumentations } = 
    require('@opentelemetry/instrumentation');
const { SequelizeInstrumentation } = 
    require(‹opentelemetry-instrumentation-sequelize');

let sequelize; 行的上方,我们可以添加跟踪提供者,这将注册正确的 Sequelize OpenTelemetry 插件:

const tracerProvider = new NodeTracerProvider({
  plugins: {
    sequelize: {
      // disabling the default/old plugin is required
      enabled: false,
      path: ‹opentelemetry-plugin-sequelize'
    }
  }
});

traceProvider 声明块下方,我们可以将提供者与 Sequelize 仪器规范关联:

registerInstrumentations({
  tracerProvider,
  instrumentations: [
    new SequelizeInstrumentation({
      // any custom instrument options here
    })
  ]
});

注意

你可以在github.com/aspecto-io/opentelemetry-ext-js/tree/master/packages/instrumentation-sequelize找到关于 Sequelize 仪表化的额外参考和选项参数。

在 Avalon Airlines 项目的根目录下,创建一个名为 tracing.js 的文件,并包含以下代码:

const opentelemetry = require("@opentelemetry/sdk-node");
const { getNodeAutoInstrumentations } = 
    require(«@opentelemetry/auto-instrumentations-node");
const sdk = new opentelemetry.NodeSDK({
  traceExporter: new opentelemetry.tracing.
      ConsoleSpanExporter(),
      instrumentations: [getNodeAutoInstrumentations()]
});
sdk.start();

现在,我们可以使用以下命令调用我们的应用程序:

node -r "./tracing.js" index.js

之后,打开浏览器访问项目的 URL(默认为 http://localhost:3000/)并刷新页面几次。几秒钟后,你应该能在你的终端看到一些类似以下的事件:

{
  traceId: '7c25880d655f67e5d8e15b83129dc95e',
  parentId: '934dc0ed012f6e37',
  name: ‹SELECT›,
  id: ‹af16347a3fbbf923›,
  kind: 2,
  timestamp: 1650124004289597,
  duration: 1616,
  attributes: {
    ‹db.system': 'mysql',
    ‹net.peer.name›: ‹127.0.0.1›,
    ‹net.peer.port': 3306,
    ‹db.connection_string':'jdbc:mysql://127.0.0.1:3306/
         airline›,
    ‹db.name›: ‹airline›,
    ‹db.user': 'root',
    ‹db.statement': 'SELECT `id`, `planeModel`, 
        `totalSeats`, `createdAt`, `updatedAt` FROM 
        `Airplanes` AS `Airplane`;›
  },
  status: { code: 0 },
  events: []
}

传统上,应用程序会将这些数据导出到收集器,如 Zipkin (zipkin.io/)、Jaeger (www.jaegertracing.io/) 或 Prometheus (prometheus.io/)。有关如何关联应用程序的遥测数据的说明,请参阅此教程:https://opentelemetry.io/docs/instrumentation/js/exporters/。

如果你打算使用 Zipkin 作为你的收集器,那么在 models/index.js 文件中的 const tracerProvider = new NodeTracerProvider({) 块下,我们将替换这一行:

provider.addSpanProcessor(new BatchSpanProcessor(new 
    ZipkinExporter()))

我们需要将其替换为以下内容:

tracerProvider.addSpanProcessor(new BatchSpanProcessor(new 
    ZipkinExporter()));

这将指示我们的跟踪提供程序将跟踪和日志导出到 Zipkin 导出器(可以同时使用多个导出器)。

摘要

在本章中,我们探讨了配置 Sequelize 日志的不同重载签名。我们还学习了如何在我们的 Node.js 应用程序中集成第三方框架,例如 OpenTelemetry。

在下一章中,我们将介绍如何将插件或适配器集成到我们的 Sequelize 实例中。下一章也将演示如何创建我们自己的适配器。

第九章:使用和创建适配器

经过几年的开发,您可能已经积累了一套常见的实用函数、其他框架的集合以及您自己的脚本库。维护所有这些动态部分可能对于一个企业项目或一系列微服务来说过于复杂。我们可以将通用代码重构为更通用的接口或模式,并将这些脚本重新分类为“适配器”(也称为“插件”)。

使用适配器可以节省我们开发时间,防止我们重复工作,并通过维护其代码库来帮助集中协作。适配器的一些例子包括将文本转换为特定的字符规则集、构建一个辅助项目,如行政仪表板,或提供缓存层。

Sequelize 通过允许通过对象原型化和其生命周期事件集成适配器和插件来扩展其行为。一旦我们熟悉了使用现有的适配器,我们将为 Sequelize 创建自己的适配器/扩展,为模型中的每个实例生成“slug URL”。

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

  • 安装、配置和集成 AdminJS 与 Sequelize

  • 将 Sequelize 与 GraphQL 集成

  • 创建我们自己的适配器

技术要求

您可以在 GitHub 上找到本章的代码文件,位于 github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch9

安装、配置和集成 AdminJS 与 Sequelize

AdminJS 是一个可以集成到各种数据库管理系统、ORM 和 Web 框架的行政仪表板。除了 AdminJS 能够为您的数据生成图表和表格之外,它还可以创建角色和访问控制列表、导出报告,并集中管理 创建、读取、更新、删除CRUD) 操作的建模。

Avalon Airline 的投资者希望我们有一个仪表板,允许我们管理航班和机票,并显示基本报告数字,例如飞机总数和毛利润。AdminJS 似乎非常适合这里;我们可以从在 Avalon Airline 的根目录中安装必要的组件开始。

在终端中,我们可以通过执行以下命令来安装包:

npm i adminjs @adminjs/express express-formidable @adminjs/sequelize tslib express-session

注意

express-formidable 模块是 @adminjs/express 包的依赖项。formidable 模块是一个具有低内存占用的高速流式多部分解析器。有关 formidable 及其功能的更多信息,您可以参考其位于 github.com/node-formidable/formidable 的 GitHub 仓库。

根据您安装的 npm 版本(8 或更高版本)以及安装的 @adminjs/sequelize 版本,您可能会遇到遗留依赖问题。由于我们的一个包(@adminjs/sequelize)需要一个旧的 Sequelize 模块路径(sequelize@sequelize/core),我们可能会遇到缺失依赖问题,这些问题可以通过启用 legacy-peer-deps 或使用 override 选项来解决。

通常,我们想要避免使用 legacy-peer-deps 选项以避免破坏性更改。我们可以在 package.json 中使用 override 选项来解决问题,这将在 docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides 中解释得更详细。在 package.json 文件中,在 scripts 块下方,我们希望添加另一个块,如下所示:

  "overrides": {
    "sequelize": "⁶"
  },

如果之前的 npm 安装步骤失败,我们可以在更新 package.json 后重试,这将解决 @adminjs/sequelize 包的 sequelize 版本要求。

现在,我们可以开始将 AdminJS 集成到我们的应用程序中。在 index.js 文件中,在最顶部,我们可以添加以下行,这将加载必要的 AdminJS 模块:

const AdminJS = require("adminjs");
const AdminJSExpress = require("@adminjs/express");
const AdminJSSequelize = require("@adminjs/sequelize");

const models = require("./models"); 行下方,我们现在可以添加以下行,这将注册 Sequelize 适配器用于 AdminJS:

AdminJS.registerAdapter(AdminJSSequelize);

在该行下方,我们可以添加我们的 AdminJS 实例并构建 Express 路由器:

const adminJs = new AdminJS({
    databases: [models.sequelize],
    resources: [
        models.Airplane,
        models.BoardingTicket,
        models.Customer,
        models.FlightSchedule,
        models.Receipts,
    ],
    rootPath: '/admin',
});

const router = AdminJSExpress.buildRouter(adminJs);

model.sequelize 是我们从 models/index.js 创建的实例。这将指示 AdminJS 使用 Sequelize 进行我们的连接。resources 键包含所有应暴露/适用于 AdminJS 的模型列表。rootPath 将是 AdminJS 的 Web 应用程序 URL 前缀。

index.js 文件中,在 app.use(bodyParser.json({ type: 'application/json' })); 行下方,我们现在可以添加 AdminJS 中间件以帮助集成到 Express:

app.use(adminJs.options.rootPath, router);

现在,当我们启动浏览器到 http://localhost:3000/admin 时,我们应该看到类似于 图 9.1 的页面。

注意

在下一章,部署 Sequelize 应用程序中,我们将介绍如何通过密码保护应用程序来防止不受欢迎的访客修改数据库。

图 9.1 – AdminJS 欢迎仪表板

图 9.1 – AdminJS 欢迎仪表板

在左侧导航栏中,我们应该看到标记为airline的数据库。点击该链接将显示我们暴露的 Sequelize 模型。点击Airplanes将显示一个包含我们模型数据的简短表格,类似于图 9.2

图 9.2 – 飞机模型表格

图 9.2 – 飞机模型表格

AdminJS 有一个小的限制;在撰写本文时,AdminJS 不支持 Sequelize 的虚拟数据类型,这些类型不是文本值。我们的BoardingTickets模型包含一个作为布尔值的虚拟类型。当我们点击登机牌菜单项时,我们会遇到类似于图 9.3的错误。

图 9.3 – AdminJS 显示来自虚拟类型的错误

图 9.3 – AdminJS 显示来自虚拟类型的错误

为了解决这个问题,我们可以通过扩展 AdminJS 资源的选项来移除属性的可见性。在新AdminJS(…)块中,在resources键下,将models.BoardingTicket行替换为以下内容:

        {
            resource: models.BoardingTicket,
            options: {
                properties: {
                    isEmployee: {
                        isVisible: false,
                    }
                }
            }
        },

这将指示 AdminJS 将isEmployee属性的可见性设置为false。现在,当我们刷新页面时,错误应该不再显示,如图 9.4所示。

图 9.4 – 使用虚拟类型解决 AdminJS 的错误

图 9.4 – 使用虚拟类型解决 AdminJS 的错误

注意

要了解 AdminJS 属性配置中可调整的设置类型,你可以参考以下 API 文档:docs.adminjs.co/PropertyOptions.xhtml

AdminJS 还会自动集成到 Sequelize 的验证系统中。因此,如果我们编辑我们的航班计划并输入了一个无效的机场,我们会看到一个类似于图 9.5的错误。

图 9.5 – AdminJS 验证集成

图 9.5 – AdminJS 验证集成

当我们使用 AdminJS 初始化应用程序时,你可能会注意到在项目根目录中自动创建了一个名为.adminjs的新文件夹。这个目录中的文件仅适用于你的实例,不适用于部署或其他团队成员。

注意

你可能已经注意到一个包含.adminjs作为其内容的.gitignore文件。.gitignore文件用于防止文件夹、文件、匹配路径等被提交到 git 的对象空间中。如果你在一个使用版本控制的项目中工作,例如 Git,那么建议忽略.adminjs目录的提交。

不论是添加、删除、修改还是验证记录,AdminJS 都提供了一个非常方便的方式来管理模型。有时,方便可能会成为障碍,我们需要以 AdminJS 无法实现的方式查看或修改我们的记录。实现这一目标的一种方法是用一个 GraphQL 库。

将 Sequelize 与 GraphQL 集成

GraphQL 相对于 REST 等替代方案提供了一些优势。我们可以使用强类型声明数据形状,关联关系层次结构,并在查询数据时减少请求的数量。

GraphQL 是一种数据存储无关的查询语言。您可以将 GraphQL 模型与典型的数据库管理系统DBMS)相关联,或者将其作为模型验证和塑形的抽象。

这里是一个 GraphQL 模式定义的示例:

type User {
  name: String!
  bio: String
  roles: [Role!]!
}
type Role {
  name: String!
}

User类型有三个属性,其中nameroles是必需的(用感叹号表示),而bio定义是一个可选的字符串。在这个例子中,User类型的roles属性将始终返回一个包含零个或多个项的数组,这些项位于括号外的感叹号([...]!)之外,而另一个感叹号表示集合中的每个项都将是非空值并返回一个Role类型。

类型仅引用一个对象,但有两个类型是为 GraphQL 本身保留的,即QueryMutation类型。查询类型保留用于定义类型集合的输入参数和关系以及关联。Mutation类型用于我们想要修改数据时。您可以将查询视为GET请求,将突变查询视为POSTPUTHTTP 方法的组合。

要查询前面示例的类型,我们将调用一个查询类型,如下所示:

type Query {
  query usersByName($name: String!) {
    users(name: $name) {
      name
      bio
      roles {
        name
      }
    }
  }
}

此示例将生成一个名为usersByName的函数,该函数有一个必需的字符串输入参数。该函数将产生任何名称与$name变量匹配的User类型。每条记录将返回名称、bio 以及与该用户关联的角色数组。返回的数据形状将类似于以下内容:

{
    "data": {
        "usersByName": {
            "users": [
                {
                    "name": "Bob",
                    "bio": "Programmer",
                    "roles": []
                },
                {
                    "name": "Bob",
                    "bio": "Lead",
                    "roles": [{"name": "Admin"}]
                }
            ]
        }
    }
}

Mick Hansen,Sequelize 的原始维护者之一,创建了一个名为 sequelize-graphql 的 NPM 包,该包将帮助我们通过 GraphQL 类型定义桥接我们的模型。要在我们的当前项目中开始使用 GraphQL 与 Sequelize,我们需要安装以下 NPM 模块:

npm i --save graphql-sequelize @graphql-yoga/node graphql-relay

graphql-sequelize库可能需要旧版本或冲突版本的graphqlgraphql-relay库。我们需要修改package.json文件中的override对象,如下所示,以解决这些问题:

  "overrides": {
    "graphql": "¹⁵",
    "graphql-relay": "⁰.10.0",
    "sequelize": "⁶"
  },

graphql-yoga包是一个专注于性能和易用性的 GraphQL 服务器框架。其 GitHub 仓库可在此处找到:github.com/dotansimha/graphql-yoga

第一步是为每个模型添加一个静态常量,称为tableName,其值应为sequelize-graphql插件中模型的表名。

我们将从models/airplane.js文件开始;在class Airplane extends Model行下,添加以下变量:

static tableName = 'Airplanes';

models/boardingticket.js文件中,在class BoardingTicket extends Model行下,添加以下变量:

static tableName = 'BoardingTickets';

models/customer.js文件中,在class Customer extends Model行下,添加以下变量:

static tableName = 'Customers';

models/flightschedule.js文件中,在class FlightSchedule extends Model行下,添加以下变量:

static tableName = 'FlightSchedules';

models/receipts.js 文件中,在 class Receipts extends Model 行下面,添加以下变量:

static tableName = 'Receipts';

现在,我们可以开始声明我们的类型定义和查询解析器模式,用于 GraphQL 服务器。在项目根目录下,添加一个名为 graphql.js 的新文件,从以下 require 命令开始:

const { createServer } = require("@graphql-yoga/node");
const { resolver } = require("graphql-sequelize");
const models = require("./models");

接下来,我们想要开始定义我们用于以后执行查询的查询接口。你可以把它想象成“C”语言项目中类似头文件的东西:

const typeDefs = `
  type Query {
    airplane(id: ID!): Airplane
    airplanes: [Airplane]
    boardingTicket(id: ID!): BoardingTicket
    boardingTickets: [BoardingTicket]
    customer(id: ID!): Customer
    customers: [Customer]
    flightSchedule(id: ID!): FlightSchedule
    flightSchedules: [FlightSchedule]
    receipt(id: ID!): Receipt
    receipts: [Receipt]
  }

在保持 typeDef 变量打开的同时,我们可以添加一个简单的 Mutation 查询示例:

  type Mutation {
    upsertAirplane(name: String!, data: AirplaneInput!): 
    Airplane
  }
  input AirplaneInput {
    planeModel: String
    totalSeats: Int
  }
  type Airplane {
    id: ID!
    planeModel: String
    totalSeats: Int
    schedules: [FlightSchedule]
  }

接下来,我们可以将我们的模型模式添加到定义中:

  type Airplane {
    id: ID!
    planeModel: String
    totalSeats: Int
    schedules: [FlightSchedule]
  }
  type BoardingTicket {
    id: ID!
    seat: String
    owner: Customer
  }
  type Customer {
    id: ID!
    name: String
    email: String
    tickets: [BoardingTicket]
  }
  type FlightSchedule {
    id: ID!
    originAirport: String
    destinationAirport: String
    departureTime: String
  }
  type Receipt {
    id: ID!
    receipt: String
  }
`;

接下来,我们希望设置我们的解析器以将类型定义与正确的 Sequelize 模型关联关联起来。让我们从查询解析器开始:

const resolvers = {
  Query: {
    airplane: resolver(models.Airplane),
    airplanes: resolver(models.Airplane),
    boardingTicket: resolver(models.BoardingTicket),
    boardingTickets: resolver(models.BoardingTicket),
    customer: resolver(models.Customer),
    customers: resolver(models.Customer),
    flightSchedule: resolver(models.FlightSchedule),
    flightSchedules: resolver(models.FlightSchedule),
    receipt: resolver(models.Receipts),
    receipts: resolver(models.Receipts),
  },

接下来,我们可以添加一个 Mutation 解析器示例:

  Mutation: {
    async upsertAirplane(parent, args, ctx, info) {
        const [airplane, created] = await models.Airplane.
        findOrCreate({
            where: {
                planeModel: args.name
            },
            defaults: (args.data || {}),
        });
        // if we created the record we do not need to 
           update it
        if (created) {
            return airplane;
        }
        if (typeof args.data !== "undefined") {
            await airplane.update(args.data);
        }
        return airplane;
    }
  },

然后,我们可以解析我们的模型关联并关闭变量:

  Airplane: {
    schedules: resolver(models.Airplane.FlightSchedules),
  },
  BoardingTicket: {
    owner: resolver(models.BoardingTicket.Customer),
  },
  Customer: {
      tickets: resolver(models.Customer.BoardingTickets),
  },
};

最后,我们可以使用模式定义创建我们的服务器并将其导出:

const server = new createServer({
  schema: {
    typeDefs,
    resolvers,
  }
});
module.exports = { server };

在项目根目录下的 index.js 文件中,在我们的 var models = require("./models") 行下面,我们可以添加以下一行:

const { server } = require("./graphql");

在我们挂载了 AdminJS 路由后,app.use(adminJs.options.rootPath, router),添加以下一行:

app.use('/graphql', server);

在我们完成 index.js 的修改后,我们可以启动我们的应用程序:

npm run start

一旦服务器启动并运行,我们可以在浏览器中通过访问以下 URL 来访问 GraphQL Yoga 的仪表板界面(图 9.6):http://localhost:3000/graphql

注意

在生产部署中,我们可能希望根据 process.env.NODE_ENV 的值禁用此路由,或者在 index.js 中的 /graphql 路由上添加基于身份验证的中间件。

图 9.6 – GraphQL Yoga 仪表板

图 9.6 – GraphQL Yoga 仪表板

我们可以通过在仪表板中执行查询来测试我们的查询和解析器。删除仪表板记事本部分的所有当前内容,并输入以下内容:

{
  airplanes {
    id
    planeModel
    totalSeats
  }
}

顶部应该有一个“播放按钮”,点击它将执行您的查询(或者,同时按下 CtrlEnter 键也可以达到同样的效果),这将产生类似于以下的结果:

{
  "data": {
    "airplanes": [
      {
        "id": "1",
        "planeModel": "A320",
        "totalSeats": 150
      }
    ]
  }
}

如果我们想要更新飞机的模型名称,我们可以使用一个 mutation 查询:

mutation {
  upsertAirplane(name:"A320", data:{planeModel:"A321"}) {
    planeModel
  }
}

这将返回以下结果:

{
  "data": {
    "upsertAirplane": {
      "planeModel": "A321"
    }
  }
}

在 GraphQL Yoga 仪表板中,顶部右方应该有一个 < Docs 链接(参见图 9.6*),点击它将打开一个抽屉面板。然后会有一个 Query 链接,这将暴露我们的查询和类型定义。这应该有助于在仪表板中使查询更加容易。

现在我们已经建立了一个 GraphQL 服务器和我们的 Sequelize 模型之间的连接,并集成了另一个提供易于使用的管理仪表板的适配器,是时候为我们自己的 Sequelize 构建适配器了。

创建我们自己的适配器

Sequelize 通过其类属性、生命周期事件和配置相当可扩展。对于创建 Sequelize 自定义适配器的示例,我们将集成一个新的数据类型,该类型将自动使用一组特定的规则将值转换为所谓的“slug URL”。slug URL 通常由连字符代替空格、小写字母,并移除所有特殊字符。

让我们看看创建我们的适配器的步骤:

  1. 我们将开始安装任何必要的包。保留特殊字符字符映射的副本可能是一项艰巨的任务,因此我们将使用一个名为 github-sluggernpm 包来帮助我们:

    npm i --save github-slugger
    
  2. 接下来,我们希望创建几个目录和一个文件,其路径为从项目根目录到 plugins/slug/index.js。在我们能够开始在该文件中编码之前,我们需要将 slug 列添加到数据库中的一个表中。我们将使用 Airplane 模型作为此示例;使用 sequelize-cli 命令,我们可以创建一个新的迁移事件:

    sequelize-cli migration:create --name add_slug_to_airplanes
    

此命令应在 migrations 目录中生成一个以 add_slug_to_airplanes.js 结尾的新文件。

  1. 将文件内容替换为以下内容:

    'use strict';
    module.exports = {
      up: async (queryInterface, Sequelize) => {
        await queryInterface.addColumn(
          'Airplanes',
          'slug',
          {
            type: Sequelize.STRING,
            allowNull: true,
          },
        );
        await queryInterface.addIndex(
          'Airplanes',
          ['slug'],
          {
            name: 'airplanes_slug_uniq_idx',
            unique: true,
          },
        );
      },
      down: async (queryInterface, Sequelize) => {
        await queryInterface.removeIndex('Airplanes', 
        'airplanes_slug_uniq_idx');
        await queryInterface.removeColumn('Airplanes', 
        'slug');
      },
    };
    

这将指示 Sequelize 在 Airplanes 表中创建一个名为 slug 的新列,作为文本值,并关联一个与该列相关的唯一索引。

  1. 要执行最新的迁移,我们将运行 db:migrate 命令:

    sequelize-cli db:migrate 
    

在列被添加到 Airplanes 表之后,我们还需要手动将其属性添加到 models/airplanes.js 文件中。

  1. totalSeats 属性块替换为以下内容:

        totalSeats: {
          type: DataTypes.INTEGER,
          validate: {
            min: {
              args: 1,
              msg: 'A plane must have at least one seat'
            }
          }
        },
        slug: {
          type: DataTypes.STRING,
          unique: true,
        },
    
  2. graphql.js 文件中,我们希望将 slug 列添加到 Airplane 类型定义中:

      type Airplane {
        id: ID!
        planeModel: String
        totalSeats: Int
        slug: String
        schedules: [FlightSchedule]
      }
    
  3. 现在,我们可以开始编辑 plugins/slug/index.js 文件,从以下代码行开始:

    const slug = require("github-slugger").slug;
    class SlugPlugin {
        use(model, options) {
            const DEFAULTS = {
                column: 'slug',
                source: 'name',
                transaction: null,
            };
            options = {...DEFAULTS, ...options};
    

这将创建一个名为 SlugPlugin 的类,其中有一个名为 use 的方法。输入参数是 model 类、一些选项及其默认值。

  1. 在这些块下面,我们将创建我们的 generateSlug 方法:

            // concat the fields for the slug
            function generateSlug(instance, fields) {
                return slug(fields.map((field) => 
                instance[field]));
            }
    
  2. 接下来,我们希望确保在更新之前 slug 不存在。我们希望创建一种查找方法和一个增量方法来找到一个唯一的值。我们可以从下面的查找方法开始:

            async function findSlug(slug) {
                return await model.findOne({
                    where: {
                        [options.column]: slug
                    },
                    transaction: options.transaction || 
                    null,
                });
            }
    
  3. 现在是增量方法;此函数将循环运行,直到使用 slug 的值和整数组合找到一个唯一的匹配项。理想情况下,在真实的生产环境中,我们会想出一种更聪明的方法来找到唯一的值(例如,附加一个哈希而不是增量变量),但为了简洁,我们将创建此函数:

            async function incrementSuffix(slugVal) {
                let found = false;
                let cnt = 1;
                let suffix = "";
                while (!found) {
                    suffix = `${slugVal}-${cnt}`;
                    found = await findSlug(suffix);
                    cnt++;
                }
                return suffix;
            }
    
  4. 我们现在可以开始创建主要事件函数。首先,我们将检查我们的 slug 的适用属性(在这个例子中是 planeModel 属性)是否已被修改。如果没有被更改,那么我们将跳过整个事件,因为没有事情要做:

            async function onSaveOrUpdate(instance) {
                const changed = options.source.
                some(function (field) {
                    return instance.changed(field);
                });
                if (!changed) {
                    return instance;
                }
    
  5. 接下来,我们将比较当前值与新生成的值。如果它们相同(例如,字母大小写的变化),则简单地跳过事件:

                let curVal = instance[options.column];
                let newVal = generateSlug(instance, 
                options.source);
                if (curVal !== null && curVal == newVal) {
                    return instance;
                }
    
  6. 现在,我们可以检查新生成的值是否唯一,如果是,则将实例的slug属性设置为该值并返回实例:

                let slugExist = await findSlug(newVal);
                if (!slugExist) {
                    instance[options.column] = newVal;
                    return instance;
                }
    
  7. 否则,我们将想要使用我们的incrementSuffix方法,并在之后返回实例:

                newVal = await incrementSuffix(newVal);
                instance[options.column] = newVal;
                return instance;
    
  8. 之后,我们可以关闭event方法,将其附加到模型的生存周期事件上,并关闭SlugPlugin类:

            }
            // use the lifecycle events for invoking the 
               onSaveOrUpdate event
            model.addHook('beforeCreate', onSaveOrUpdate);
            model.addHook('beforeUpdate', onSaveOrUpdate);
        }
    }
    
  9. 最后,我们可以导出我们插件的一个实例以及类定义本身:

    const instance = new SlugPlugin();
    module.exports = instance;
    module.exports.SlugPlugin = instance;
    
  10. models/airplanes.js文件中,我们希望将我们的新插件与模型集成。在文件顶部,我们可以像这样包含插件:

    const slugPlugin = require('../plugins/slug');
    
  11. 在模型的定义之后,在return Airplane行之前,我们可以将slug插件与模型关联:

      slugPlugin.use(Airplane, {
        source: ['planeModel']
      });
    

这将告诉我们的插件在生成 slug 值时使用planeModel属性作为源字段。

  1. 为了测试我们的插件,我们可以前往位于http://localhost:3000/graphql的 GraphQL 仪表板,并输入以下命令:

    mutation {
      upsertAirplane(name:"A321", data:{planeModel:
      "A321 B"}) {
        planeModel
        totalSeats
        slug
      }
    }
    

这将找到并更新我们的 A321 飞机的planeModel值,同时设置一个slug值,如下所示:

{
  "data": {
    "upsertAirplane": {
      "planeModel": "A321 B",
      "totalSeats": 150,
      "slug": "a321-b"
    }
  }
}

这完成了我们的自定义 Sequelize 适配器部分。您可以在使用 Sequelize 的任何其他项目中使用plugins/slug/index.js适配器。请随意将slug列添加到其他模型中,但请确保遵循必要的步骤:

  1. 生成迁移文件并将列更改迁移到数据库。

  2. 更新graphql.js文件,添加适当的数据类型定义。

  3. 在适用模型的文件中包含plugin库,并使用use方法将插件与模型关联。

摘要

在本章中,我们介绍了安装与数据库集成的仪表板的过程,使用第三方库集成 GraphQL,以及创建我们自己的 Sequelize 适配器,该适配器将自动添加 slug 值。

在下一章中,我们将开始开发我们的网站,使其更具生产准备性和功能完整性。这些功能包括列出时间表、订购票务和输入客户信息。

第十章:部署 Sequelize 应用程序

在安装了管理仪表板、配置了我们的 Web 应用程序以预订航班并构建了后端服务器之后,我们现在可以开始开发前端界面并部署应用程序。正好及时,因为我们的董事会成员希望看到一些进展,并且他们希望看到购买票证的运行原型。

在本章中,为了满足董事会成员的要求,我们需要执行以下操作:

  • 重构一些当前的路由并添加另一个列出航班时刻表的路由

  • 集成 Express 的静态中间件并保护管理界面

  • 创建一个页面来列出和预订航班

  • 将应用程序部署到 Fly.io 等服务

技术要求

对于本章的任务,我们将安装以下附加软件:

  • 一个名为 Git 的版本控制管理器

  • 部署到云应用程序平台的 Fly.io CLI

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Supercharging-Node.js-Applications-with-Sequelize/tree/main/ch10

重构并添加航班时刻表路由

在我们开始创建购买登机牌的客户界面之前,我们需要对我们的代码库进行一些调整。让我们首先创建一个位于routes/airplanes.js的新文件,并将app.post('/airplanes', …)app.get('/airplanes/:id', …)块移动到该文件中,如下所示:

async function getAirplane(req, res) {
    const airplane = await models.Airplane.findByPk
     (req.params.id);
    if (!airplane) {
        return res.sendStatus(404);
    }
    res.json(airplane);
}
exports.getAirplane = getAirplane;

此路由将根据主键返回一个Airplane模型记录,主键在 Express 的请求对象中定义(由:id符号表示)。如果没有找到记录,则返回404(未找到)状态。

接下来,我们将从routes/flights.js中的createAirplane代码块将其移动到routes/airplanes.js文件中:

async function createAirplane(req, res) {
    const { name, seats } = req.body;
    try {
        const airplane = await models.Airplane.create({
            planeModel: name,
            totalSeats: seats,
        });
        return res.json(airplane);
    } catch (error) {
        res.status(500).send(error);
    }
}
exports.createAirplane = createAirplane;

routes/flights.js中,我们希望添加一个名为flightSchedules的新处理程序:

async function flightSchedules(req, res) {
    const records = await models.FlightSchedule.findAll({
       include: [models.Airplane]
    });
    res.json(records);
}
exports.flightSchedules = flightSchedules;

之后,在项目根目录下的index.js文件中,我们可以删除app.get('/', …)块并修改需要匹配新方法路径的路线要求块(在我们删除的块上方)如下:

const { bookTicket } = require("./routes/tickets")
const { createSchedule, flightSchedules } = 
require("./routes/flights");
const { getAirplane, createAirplane } = 
require("./routes/airplanes");

app.get('/airplanes/:id', …)块现在应如下所示:

app.get('/airplanes/:id', getAirplane);

在下面,我们可以添加航班时刻表路由:

app.get('/flights', flightSchedules);

接下来,我们将想要调整客户模型返回的错误。在models/customers.js中,将现有的属性替换为以下代码:

    name: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: {
            msg: "A name is required for the customer",
        }
      }
    },
    email: {
      type: DataTypes.STRING,
      validate: {
        isEmail: {
            msg: "Invalid email format for the customer",
        }
      }
    }

最后,对于航班和预订票证的最后修改,我们需要对routes/tickets.js文件进行一些调整。首先,我们希望在文件顶部添加 Sequelize 的ValidationError

const { ValidationError } = require("@sequelize/core");

由于我们将在预订过程中查找或创建客户,我们希望将req.body行更改为以下内容:

const { scheduleId, seat, name, email } = req.body;

在此行下方,我们将添加以下内容:

const [customer] = await models.Customer.findOrCreate({
    where: {
        email,
    },
    defaults: {
        name,
    }
});

这将告诉 Sequelize 使用电子邮件作为键来查找或创建一个客户记录,并且将从 POST 请求中填充记录的名称(如果记录是新的)。

await schedule.addBoardingTicket(…) 块上方,我们希望添加一个定义新创建登机牌客户关联的方法:

await boardingTicket.setCustomer(
 customer,
 { transaction: tx }
);

此文件剩余的修改是将 catch 块替换为以下代码:

    } catch (error) {
        if (error instanceof ValidationError) {
            let errObj = {};
            error.errors.map(err => {
               errObj[err.path] = err.message;
            });
            return res.status(400).json(errObj);
        }
        if (error instanceof Error) {
            return res.status(400).send(error.message);
        }
        return res.status(400).send(error.toString());
    }

此错误块将检查传入的错误是否为 Sequelize ValidationError 类型,如果是,则将错误映射到 errorObj,其中列(err.path)作为键,错误消息(err.message)作为值 - 然后,它将返回 error 对象。下一个 if 块将检查错误是否为通用的 Error 类型,如果是,则返回 error.message 值 - 否则,它将返回 error 变量作为字符串。这将提供一种更方便的方式来处理错误,以便快速原型网站。

这些都是管理航班和创建机票所必需的所有修改。下一步是为我们的静态资产打下基础并确保我们的管理仪表板安全。

集成 Express 的静态中间件和确保管理界面安全

在将我们的应用程序公开给公众之前,我们需要确保管理仪表板路由安全,同时公开静态资产以供前端开发使用。首先,我们希望创建一个新目录,并在 public/index.xhtml 中放置一个空文件。之后,我们可以开始修改位于项目根目录中的 index.js 文件。在顶部,我们需要 Node.js 的路径模块:

const path = require("path");

app.use('/graphql', server) 块下方,我们希望告诉 Express 服务器公共目录中找到的静态资产:

app.use(express.static(path.join(__dirname, "public")));

Express 将在级联到我们的 API 路由(例如 /airplanes/flights)之前,在公共目录中尝试找到相关路由的匹配文件。我们在这里使用 path.join 的原因是为了避免相对路径的不匹配,这允许我们从任何目录运行应用程序。

接下来,我们希望确保我们的管理仪表板安全 - 为了简洁起见,我们将使用 HTTP 认证方法。这需要我们安装 express-basic-auth 包:

npm i --save express-basic-auth

index.js 的顶部添加要求:

const basicAuth = require("express-basic-auth");

app.use(adminJs.options.rootPath, router) 块替换为以下内容:

app.use(adminJs.options.rootPath, basicAuth({
        users: { 'admin': 'supersecret' }, challenge: true,
        }), router);

这将告诉 Express 在访问 AdminJS 根路径时请求用户名和密码组合(分别是 adminsupersecret)。现在,当我们启动应用程序并转到 http://localhost:3000/admin 时,我们应该会看到一个类似于 图 10.1 中的登录对话框:

图 10.1 – 管理登录

图 10.1 – 管理登录

现在,我们的 AdminJS 路由已经安全,我们可以开始创建客户在访问应用程序时将看到的前端页面。

注意

在实际场景的应用程序中,我们不会使用基本身份验证,而是会使用其他形式的身份验证,例如 JSON Web Tokens 或单点登录服务。

创建一个列出和预订航班的页面

对于这个应用程序,我们将需要两个外部库来帮助构建应用程序的前端组件。第一个库是Bulma,这是一个为快速原型设计而设计的 CSS 框架,不需要自己的 JavaScript 库。有关 Bulma 的更多信息,您可以访问其网站,位于bulma.io/。下一个库是AlpineJS,这是一个框架,它通过使用 HTML 标签和标记来帮助我们避免编写 JavaScript 来修改状态或行为。更多信息可以在alpinejs.dev/找到。

注意

可以用作 AlpineJS 替代品的其他出色的前端框架包括 VueJS、React 或 Deepkit。AlpineJS 被选为这本书的原因是其最小化的设置和需求。

让我们从最基本的需求开始,这是网站简单页眉部分的 HTML:

  1. public/index.xhtml中,添加以下代码:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Welcome to Avalon Airlines!</title>
      <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
      <script src="img/cdn.min.js" defer></script>
    </head>
    <body>
      <section class="section">
        <div class="container">
          <h1 class="title">
            Welcome to Avalon Airlines!
          </h1>
          <p class="subtitle">
            Where would you like to go 
             <strong>today</strong>?
          </p>
        </div>
      </section>
    </body>
    </html>
    
  2. 在第一个<section>之后,我们希望添加另一个带有两个列分隔的容器:

      <section class="section">
        <div class="container">
          <div class="columns" x-data="{
                        flights: [],
                        selected: {}
                      }" x-init="fetch('/flights')
                          .then(res => res.json())
                          .then(res => flights = res)">
            <div class="column">
            </div>
            <div class="column">
          </div>
        </div>
      </section>
    

x-data属性将告诉 AlpineJS 我们的模型和数据将保持什么形状。这些数据将被传播到子元素。x-init属性将在元素的初始化时运行,并从我们的 API 调用/flights。之后,我们获取结果并将它们转换为 JSON 对象,然后我们将 JSON 响应分配给x-data属性中的flights数组。

  1. 在第一列,从我们刚刚创建的章节开始,我们希望创建一个表格,以渲染所有可用的航班:

    <table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
        <thead>
          <tr>
            <th>Origin</th>
            <th>Departure</th>
            <th>Departure Time</th>
            <th>Model</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <template x-for="flight in flights">
            <tr>
              <td x-text="flight.originAirport"></td>
              <td x-text="flight.destinationAirport"></td>
              <td x-text="flight.departureTime"></td>
              <td x-text=
               "flight.Airplane.planeModel"></td>
              <td><button x-on:click="selected = flight" 
               class="button is-primary is-light is-
               small">Book
                  Flight</button></td>
            </tr>
          </template>
        </tbody>
      </table>
    

AlpineJS 将识别x-for属性,它的工作方式与其他语言的for循环类似——该块内的任何内容都将为每次迭代渲染。如果flights数组为空,则template块将不会渲染。x-on:click属性将为button元素添加一个点击事件监听器,该监听器将选定的变量(来自父元素的x-data模型的一部分)分配给相关的航班条目。

  1. 接下来,我们将想要创建处理我们的表单提交的逻辑。在关闭<body>标签(</body>)的上方,我们希望添加以下内容:

    <script>
      function flightForm() {
        return {
          data: {
            email: "",
            name: "",
            seat: "",
            success: false,
          },
          formMessages: [],
          loading: false,
    

dataformMessagesloading变量都是 AlpineJS 的状态。我们可以选择任何我们想要的名称,因为它对 AlpineJS 来说并不重要。

  1. 现在,对于提交事件处理部分,在loading: false块下方添加以下内容:

          submit(e) {
            this.loading = true;
            fetch("/book-flight", {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                "Accept": "application/json",
              },
              body: JSON.stringify({
                ...this.data,
                scheduleId: this.selected.id,
              }),
            })
    

一旦提交事件被调用,就会发出一个带有必要的 JSON 头和正文参数的POST /book-flight请求。this.selected.id变量将引用父元素的x-data模型。

  1. 在获取之后,我们需要处理适当的响应。让我们从成功的路径开始,并在获取块之后添加以下代码:

              .then(async (response) => {
                const { headers, ok, message, body } = 
                 response;
                const isJson = headers.get('content-
                 type')?.includes('application/json');
                const data = isJson ? await 
                 response.json() : await response.text();
                if (!ok) {
                  return Promise.reject(isJson ? 
                  Object.values(data) : data);
                }
               // boarding ticket was successfully created
                this.formMessages = [];
                this.data = {
                  email: "",
                  name: "",
                  seat: this.data.seat,
                  success: true,
                }
              })
    

此方法将检查数据是否为 JSON 或纯文本。然后,它将检查响应是否为 OK(如果返回错误,则返回一个被拒绝的承诺)。如果机票成功创建,我们将重置电子邮件、名称和座位到它们的初始值,并将 success 设置为 true

注意

在上一个例子中,我们将名称和电子邮件设置为空字符串以清除当前表单的数据。如果我们省略这些显式值,那么当 flightForm 出现在屏幕上时,AlpineJS 将显示名称和电子邮件输入框及其之前的值。

  1. 之后,我们可以添加 catchfinally 块并关闭剩余的脚本:

              .catch((err) => {
                this.formMessages = Array.isArray(err) ? 
                 err : [err];
              })
              .finally(() => {
                this.loading = false;
              });
          },
        };
      }
    </script>
    

捕获到的错误将自身传播到 formMessages 数组中,无论成功与否,我们都会想要使用 finally 块将加载状态设置为 false

  1. 让我们回到我们之前创建的两个列的部分——在第二个列中,我们想要添加一个成功消息以及表单本身。我们将从一个显示当前选定的航班信息的部分开始:

    <div x-show="!!selected.id">
      <section class="hero is-info">
        <div class="hero-body">
          <p class="title">
            <span x-text="selected.originAirport"></span> &#8594; <span x-text="selected.destinationAirport">
            </span>
          </p>
          <p class="subtitle">
            Departs at <span x-text="selected.
            departureTime"></span>
          </p>
        </div>
      </section>
    

x-show 属性会在值返回为 true 时隐藏一个元素。接下来的几个元素将使用来自父元素 x-data 模型的选中对象属性的数据。此元素应该在选择航班之前隐藏。x-text 属性将告诉 AlpineJS 将元素的 innerText 渲染为与属性关联的值(例如,selected.originAirportselected.departureTime)。

  1. 一旦设置了 hero 部分,我们将添加一个表单用于成功预订航班时的成功消息:

    <form x-data="flightForm()" @submit.prevent="submit">
      <div x-show="!!data.success">
        <section class="hero is-primary">
          <div class="hero-body">
            <p class="title">
              Your boarding ticket has been created!
            </p>
            <p class="subtitle">
              Your seat for this flight is <span 
               x-text="data.seat"></span>
            </p>
          </div>
        </section>
        <div class="mt-4 field is-grouped is-grouped-  
         centered">
          <p class="control">
            <a class="button is-light" 
              x-on:click="selected = {}; data.success =     
              false; data.seat = ''">
              OK
            </a>
          </p>
        </div>
      </div>
    

我们在 <form> 标签中封装了 flightForm 的状态和事件。@submit.prevent="submit" 属性将告诉 AlpineJS 在提交事件时防止冒泡传播,并在 flightForm 方法中使用我们的 submit 函数。

接下来,我们将检查 success 是否为 true,如果是,则显示订单确认部分。我们想要有一种方式在客户购买机票后重置状态(以防他们想要购买另一张机票),这就是我们点击 OK 按钮时 x-on:click 事件所做的事情。

  1. 现在,对于实际的表单,我们将检查 data.success 是否为 false,如果是,则显示带有一些基本字段的表单。在相同的 form 属性中,添加以下内容:

    <div x-show="!data.success">
      <div class="field pt-4">
        <label class="label">Full Name</label>
        <div class="control">
          <input class="input" type="text" x-model=
           "data.name" placeholder="e.g Alex Smith">
        </div>
      </div>
      <div class="field">
        <label class="label">Your Email</label>
        <div class="control">
          <input class="input" type="email" 
            x-model="data.email"
            placeholder="e.g. alexsmith@avalon-
            airlines.com">
        </div>
      </div>
      <div class="field">
        <label class="label">Seat Selection</label>
        <div class="control">
          <input class="input" type="text" 
            x-model="data.seat" placeholder="e.g. 1A">
        </div>
      </div>
    

x-model 属性会将输入的值与 x-data 对象绑定(例如,x-model="data.email" 将与其关联 flightFormdata.email 属性)。

  1. 在此代码下方,我们可以添加用于购买机票或取消订单的调用操作按钮:

    <div class="field is-grouped is-grouped-centered">
      <p class="control">
        <button type="submit" :disabled="loading" 
        class="button is-primary">
          Purchase Ticket
        </button>
      </p>
      <p class="control">
        <a class="button is-light" x-on:click="selected = {}; 
        data.success = false; formMessages = []">
          Cancel
        </a>
      </p>
    </div>
    

:disabled 属性是 AlpineJS 的简写代码,用于在特定条件下禁用特定元素(在我们的情况下,这将是指示变量)。点击 data.success 变量将其设置为 false,并将 formMessages 设置为空数组。

  1. 最后,我们可以为处理 formMessages 变量添加一个模板,并关闭剩余的 HTML 标签:

                    <template x-for="message in 
                     formMessages">
                      <article class="message is-warning">
                        <div class="message-header">
                          <p>A correction is required</p>
                        </div>
                        <div x-text="message" class=
                         "message-body"></div>
                      </article>
                    </template>
                  </div>
                </form>
    

我们的前端应用程序现在应该完成了。如果我们访问 http://localhost:3000/,它应该类似于图 10.2。点击预订航班按钮应该生成类似于图 10.3的内容:

图 10.2 – 欢迎来到 Avalon 航空公司!

图 10.2 – 欢迎来到 Avalon 航空公司!

图 10.3 – 预订航班

图 10.3 – 预订航班

当我们点击购买票务而没有输入任何信息时,我们应该看到一些警告,如图图 10.4所示:

图 10.4 – Sequelize 的警告

图 10.4 – Sequelize 的警告

当我们输入适当的信息时,应用程序将创建一个新的客户和登机牌,并显示成功消息,如图图 10.5所示:

图 10.5 – 成功消息

图 10.5 – 成功消息

访问管理仪表板将确认我们的票务和客户账户已成功创建。我们可以在 http://localhost:3000/admin/resources/BoardingTickets(请记住使用适当的凭据登录)查看登机牌,类似于图 10.6

图 10.6 – 显示登机牌的管理仪表板

图 10.6 – 显示登机牌的管理仪表板

看起来我们的应用程序已经准备好部署了。在下一节中,我们将讨论在云应用程序平台(如 Fly.io)上设置环境的要求。

部署应用程序

在我们开始之前,我们需要确保我们的项目已初始化为 git 仓库,如果你的机器没有安装 git,你可以在以下链接中找到如何安装二进制文件的说明git-scm.com/book/en/v2/Getting-Started-Installing-Git。如果你一直在跟随,但尚未将项目初始化为 git 仓库,你可以在项目的根目录中运行以下命令:

git init

对于部署过程,我们将使用一个名为Fly.io的云托管服务(fly.io/)。Fly.io 提供了一个有用的命令行工具,可以帮助我们注册和验证账户,同时使应用程序部署更加容易。有关如何开始使用 Fly.io 的 CLI 的详细说明,请参阅fly.io/docs/hands-on/install-flyctl/

对于 MacOS 用户,使用 Homebrew,我们可以使用以下命令安装二进制文件:

brew install flyctl

Linux 用户可以使用以下命令安装二进制文件:

curl -L https://fly.io/install.sh | sh

对于 Windows 用户,Fly.io 建议使用 PowerShell 下载二进制文件:

iwr https://fly.io/install.ps1 -useb | iex

一旦二进制安装完成,我们需要登录或注册一个新账户,然后创建一个新的应用程序。如果您之前没有创建您的免费 Fly.io 账户,我们可以使用以下命令开始:

flyctl auth signup

或者,如果我们之前已经注册了一个账户,我们可以通过以下方式进行身份验证:

flyctl auth login

在我们进行身份验证后,现在我们可以部署我们的应用程序:

flyctl launch

此命令将要求我们输入应用程序名称和区域,我们可以将这些值留空或使用默认值。我们还将被询问是否想要创建一个 Postgres 数据库并立即部署应用程序,我们应该通过输入“n”键作为响应来拒绝。以下内容应与您的屏幕类似:

Creating app in /Users/daniel/Documents/Book/code/ch10
Scanning source code
Detected a NodeJS app
Using the following build configuration:
	Builder: heroku/buildpacks:20
? App Name (leave blank to use an auto-generated name):
Automatically selected personal organization: Daniel Durante
? Select region: iad (Ashburn, Virginia (US))
Created app nameless-shape-3908 in organization personal
Wrote config file fly.toml
? Would you like to set up a Postgresql database now? No
? Would you like to deploy now? No
Your app is ready. Deploy with `flyctl deploy`

不要立即部署应用程序。我们首先需要通过 Fly.io 应用程序启用 MySQL。目前,Fly.io 不提供在同一个应用程序中作为我们的网络应用程序的 MySQL 数据库进行 sidecar 的方式。解决方案是创建一个仅包含 MySQL 的单独 Fly.io 应用程序。

在项目的根目录中,我们希望在名为“fly-mysql”的新文件夹中创建一个文件夹,并在该文件夹中运行以下命令:

fly launch

以与之前 fly launch 命令中相同的方式回答问题。现在,我们的数据库需要存储在某个地方,所以让我们首先在 Fly.io 上创建一个卷,并选择与之前步骤相同的区域。在 *fly-mysql* 目录中运行以下命令以创建一个新的卷:

fly volumes create mysqldata --size 1

注意

fly volumes create <name> 的 “--size” 参数表示以千兆字节为单位的数字。有关 volumes Fly.io 子命令的更多信息,请参阅 fly.io/docs/reference/volumes/

现在,我们可以为 MySQL 实例设置密码(将“password”替换为更合适的密码):

fly secrets set MYSQL_PASSWORD=password MYSQL_ROOT_PASSWORD=root_password

在整个过程中,Fly.io 为其应用程序创建了一个 fly.toml 文件(一个位于项目根目录中的用于我们的网络应用程序,另一个位于 fly-mysql 目录中的用于 MySQL)。这类似于 Heroku 的 Procfile 或 CloudFlare 的 wrangler.toml 文件。在 fly.toml 文件中,我们希望在第一行(应用程序的名称)或从 kill_signal 行开始替换其内容,如下所示:

kill_signal = “SIGINT”
kill_timeout = 5
[mounts]
  source=”mysqldata”
  destination=”/data”
[env]
  MYSQL_DATABASE = “avalon_airlines”
  MYSQL_USER = “avalon_airlines”
[build]
  image = “mysql:5.7”
[experimental]
  cmd = [
    “--default-authentication-plugin”,
    “mysql_native_password”,
    “--datadir”,
    “/data/mysql”
  ]

修改完文件内容后,我们可以将我们的 MySQL 应用程序扩展到拥有 256 MB 的 RAM 并部署 MySQL 实例:

fly scale memory 256
fly deploy

现在,回到项目的根目录,我们可以通过运行以下命令将一个名为 DATABSE_URL 的环境密钥添加到我们的网络应用程序的 Fly.io 配置中:

flyctl secrets set DATABASE_URL=mysql://avalon_airlines:<YOUR PASSWORD>@<YOUR MYSQL’S APPLICATION NAME>.internal/avalon_airlines

YOUR_PASSWORD 替换为之前为 MySQL 应用程序设置的 MYSQL_PASSWORD 密码。您的 MySQL 应用程序名称应在带有 app 键的 fly-mysql/fly.toml 文件中可用。

注意

如果您忘记了应用程序的名称,Fly.io CLI 提供了一种使用 flyctl apps list 命令列出您账户中所有应用程序的方法。

我们需要对 package.json 文件进行一些修改。由于应用程序的构建器正在使用 Heroku 的构建包,应用程序将默认使用最新的 start 脚本,目前使用 nodemon。我们可以确保应用程序使用正确的 Node.js 版本构建,并通过在 package.json 中的 start 脚本替换来删除 nodemon 依赖项,如下所示:

  “scripts”: {
    “start”: “node index.js”,
    “dev”: “nodemon index.js”
  },
  “engines”: {
    “node”: “16.x”
  },

现在,当我们本地开发应用程序时,我们将想要执行 npm run dev 而不是 npm run start

注意

关于 Heroku 的 Node.js 构建包的更多信息以及注意事项,可以在 devcenter.heroku.com/articles/nodejs-support 找到。

从 Avalon 航空公司项目开始,我们需要打开并修改 config/index.js 文件,并用适当的数据库连接值替换生产对象:

    “production”: {
        “use_env_variable”: “DATABASE_URL”,
        “dialect”: “mysql”
    }

Fly.io 将在容器集群中部署,该集群公开动态范围内的端口。由于这一规定,我们被要求修改 index.js 底部的 app.listen(3000, …)

app.listen(process.env.PORT || 3000, function () {
    console.log(“> express server has started”);
});

这将使用 PORT 环境变量,如果未找到环境变量,则默认为 3000,正确地在 Fly.io 的生态系统上公开我们的 Express 应用程序。在 fly.toml 文件的项目根目录中,我们还需要进行一项更改,即用以下内容替换 [env] 块:

[env]
  PORT = “8080”
  NODE_ENV = “production”

其他内容应保持不变,现在,我们可以部署并打开我们的应用程序:

flyctl deploy
flyctl open

注意

您可能会收到类似“无法找到模块 'sequelize'”的错误,这可能是来自第三方应用程序依赖项,例如 Admin.js。作为一个临时解决方案,我们可以在项目目录中的终端中手动安装并保存原始 Sequelize 库,方法是输入 npm i sequelize 并重新部署您的应用程序。

您可能会注意到网站看起来有点简陋,我们可以前往 /admin 控制台路由并开始填充我们的飞机库存和航班计划。一旦完成,我们就可以开始处理和预订 Avalon 航空公司的机票了!

图 10.7 – Avalon 航空公司主页,显示已安排的航班!

图 10.7 – Avalon 航空公司主页,显示已安排的航班!

摘要

在本章中,我们介绍了添加具有生成航班计划列表和创建登机牌功能的前端页面的过程。我们还学习了如何将我们的应用程序部署到云应用程序环境中。

恭喜!我们已经完成了从熟悉 Sequelize 到部署基于 Sequelize 的 Web 应用的过程。在现实场景中,我们可能还想进行一些调整,例如安全存储数据库凭证、设置事务性电子邮件、添加更多页面、处理信用卡以及拥有实际的座位库存管理系统。到目前为止,剩下的就取决于你了,只有天空才是极限!希望这对你来说是一个令人满意的开端!这确实应该是,因为 Avalon 航空公司的董事会成员到目前为止都很满意,他们已经决定资助我们下一轮。

posted @ 2025-10-24 10:00  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报