Node-和-Redis-可扩展应用构建指南-全-

Node 和 Redis 可扩展应用构建指南(全)

原文:zh.annas-archive.org/md5/2821efef2b39d81bf084e108b594a507

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章

Node.js 简介

简介

Node.js 在全球 IT 市场上的普及度日益增长。通过这本书,任何 JavaScript 开发者都可以轻松地从基础到高级学习 Node.js。本章将讨论 Node.js 的基础和架构。我们还将学习如何编写简单的 Node.js 程序。

结构

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

  • 定义 Node.Js 及其应用场景

  • Node.Js 的优缺点

  • 在各种平台上安装 Node.Js

  • 理解事件驱动编程

  • Node.js 架构

  • 编写 HTTP 和 HTTPS 服务器

  • 使用 Cluster 模块

定义 Node.Js

当 Ryan Dahl 展示了他的杰出工作,2009 年 JSConf 上的 Node.js,这标志着新时代的开始。他表示,在大多数顶级语言中,并发是通过线程实现的,而使用线程存在某些问题,因为线程之间的上下文切换成本高昂。通过事件循环,他展示了 Node.js 实现的并发性远高于任何现有语言。会议上的与会者欢迎这一想法,并鼓掌欢迎 Dahl。因此,编程世界期待已久的转变开始了。

Node.js 是一个开源的跨平台 JavaScript 运行时环境。它声明任何人都可以免费在任何操作系统上使用它,例如 Windows、Linux、Unix、Mac 等。JavaScript 是 Node.js 的基础。任何 node.js 应用的代码都是用 JavaScript 编写的。此代码在 Google Chrome 的 V8 JavaScript 引擎上运行,该引擎直接将源代码转换为机器代码,然后无需解释即可执行,无需浏览器即可执行。Node.js 为代码的运行提供了必要的环境。

图 1.1: Node.js 架构

图 1.1 展示了 Node.js 的高级架构。尽管 Node.js 是单线程的,但它通过异步非阻塞 I/O 操作的机制,在每次处理大量并发请求,这种机制由 **libuv** 库提供,该库以多线程的方式自行执行。

由于非阻塞 I/O 操作,Node.js 与其他语言相比速度更快,因为请求不会等待其响应,而是并行执行另一个请求。所有请求首先发送到事件队列,在事件循环中处理,然后通过队列发送回 V8 引擎,如 Node.js 架构中所示。关于事件循环的更多内容将在本章后面的“事件驱动机制”部分中详细阐述。

Node.js 不仅是为了后端服务器编程应用而编写的,而且还作为节点模块开发并用于客户端,这对开发者来说是有益的,因为两边的语言相同。

Node.js 的应用

由于其特性和不同类型的应用,Node.js 在 IT 行业中的应用正在以较快的速度增长。以下是一些与 Node.js 相关的不同类型应用的例子。

单页应用程序

目前,许多组织和公司通过开发作为单页应用程序的服务器端应用程序的 Node.js 应用程序,为他们的客户提供复杂和实时的解决方案。例如,Gmail、Twitter、Facebook、Trello 以及许多其他应用程序都是作为 SPA(单页应用程序)开发的。单页应用程序通过在单个网页上重写数据而不是重新加载整个网页,与用户的操作进行通信。

实时应用

Node.js 是实时应用的理想模型,因为它可以同时响应多个请求。如果有大量用户需要实时响应,Node.js 是更好的选择。您可以使用 Node.js 与 WebSockets 结合,以实现持续连接并提供更快的响应时间。音频、视频、聊天、多人游戏和股票交易等应用程序都是通过这种方式开发的。

物联网设备应用

由于其较快的响应时间和处理大量并发请求的能力,Node.js 是物联网(IoT)应用程序的良好选择,其中设备或传感器连接到互联网并持续发送大量数据。火灾检测、噪音污染测量、健身追踪、健康监测等物联网用例是 Node.js 发挥重要作用的许多应用之一。

数据流应用程序

Node.js 允许以流的形式使用抽象接口进行数据处理。大型媒体文件被分割成小块,并以缓冲区形式发送。这些缓冲区被转换成有意义的数据。Netflix 类似的流媒体服务使用 Node.js,其中数据以块的形式传输,而不是整个大流,这减少了在流传输过程中的加载和延迟。

Node.js 的用途不仅限于前面提到的应用类型,还包括在 Node.js 中开发的许多其他类型的应用,例如制作代理或信令服务器、监控基于数据的应用程序等。

Node.js 的优点

Node.js 是一个非常强大的 JavaScript 运行时环境。它允许开发者构建高性能和可扩展的应用程序。Node.js 提供的一些关键优势如下:

  • 跨平台:Node.js 提供了跨平台功能,因此应用程序可以轻松地在任何操作系统上开发并在任何平台上部署。Node.js 支持的关键平台包括 Windows、Mac(Intel)、Mac(ARM)和 Linux(Intel/ARM)。可能每个主要平台都得到了支持。

  • 高性能:Node.js 由于异步非阻塞 I/O 操作而提供高性能,这些操作可以并行执行请求,而无需等待任何其他请求的响应。

  • 易于扩展:Node.js 本身是单线程的,但在高流量下,它可以通过“cluster”模块同时处理大量请求来扩展,该模块创建子进程并减少应用程序的负载。

  • 缓存:Node.js 允许将数据存储在临时内存中,这些数据不经常更新,这可以减少加载时间并节省数据库事务。这被称为缓存。

  • 庞大的社区:自从 Node.js 出现以来,其社区规模每天都在增加。用于编程的语言是 JavaScript,它是互联网的支柱,几乎每个前端开发者都已经熟悉它。这使得学习变得容易,并使社区迅速增长。有超过 130 万个开源库可供使用。

此外,还有很多其他优点,如成本效益、易于学习和适应性。Node.js 是一种真正有所不同的技术。

Node.js 的缺点

Node.js 也有一些缺点。然而,许多这些缺点可以通过最佳实践来克服。

  • 单线程:Node.js 是单线程的,这既是优点也是缺点,因为它无法快速处理重量级的 CPU 密集型计算。当需要更多 CPU 进行处理的请求进入事件循环时,它们会不断堆积,因为直到完成一个请求,它才不会从事件队列中选取其他请求。然而,这仅在只有 CPU 密集型任务时才会发生。如果请求需要一些 I/O 发生,另一个请求将在请求等待 I/O 完成时被选取。CPU 密集型任务会降低性能并延迟响应。例如,对于搜索算法和数学计算,当时复杂性很高,由于性能不佳,Node.js 不推荐使用。

  • 回调地狱:在 Node.js 中进行异步编程对一些开发者来说可能具有挑战性,尤其是在使用回调时。回调地狱是一种回调函数嵌套的情况。这可能会使代码难以阅读和维护。然而,为了避免这种情况,开发者可以使用 promises、async-await 或如 Async.js 之类的库。

  • 库兼容性:尽管有超过一百万个库可用,但那些由个人开发者开源的库可能没有更新到最新版本。这有时会使在项目中使用这些库变得困难。

安装 Node.js

现在我们已经对 Node.js 是什么以及它提供了什么有了高层次的理解,让我们跳转到设置部分。在您的系统中下载和安装 Node.js 有不同方式,但在这里,我们提供最简单和最佳的方式。根据您的操作系统,从其官方网站(nodejs.org/en/download)下载 Node.js 的 LTS(长期支持)版本。在网站上,会有 LTS 和当前版本,请选择 LTS 版本,因为它更稳定,并且推荐用于复杂项目。

在撰写本文时,Node.js 版本 20 是激活状态。

图 1.2:Node.js 版本

上述最新的发布时间表可以在 Node.js GitHub 页面查看— github.com/nodejs/release#release-schedule

在 Linux/Ubuntu 上安装 Node

NPMNode 包管理器)是 node.js 的默认包管理器,也是一个 JavaScript 软件包的库。它是开源的,这样开发者就可以通过 npm 免费在他们的项目中安装其他模块。

Node 版本管理器(NVM)是一个 shell 脚本,用于管理多个 node 版本,并在不同的项目中使用它。

我们可以通过实际案例来强调通过NVM安装 Node.js 的重要性。

使用 NVM,您可以在同一台机器上轻松管理多个 Node.js 版本。以下是它如何帮助的:

  • 版本管理:NVM 允许您在系统中安装多个 Node.js 版本。这意味着您可以根据项目的需求无缝地在不同版本之间切换。

  • 隔离环境:通过 NVM 安装的每个 Node.js 版本都与其他版本隔离。这确保了对一个版本的更改不会影响到其他版本。当您在处理具有冲突依赖项的项目或需要与旧版本保持兼容性时,这尤其有用。

  • 灵活性:使用 NVM,您可以轻松地在 Node.js 版本之间切换。这允许您在不同的版本上测试您的应用程序,确保兼容性和稳定性。

  • 特定项目的版本管理:NVM 允许您指定特定项目所需的 Node.js 版本。这确保了每个项目都使用正确的 Node.js 版本,而不会干扰到其他项目。

  • 轻松更新:NVM 简化了将 Node.js 更新到最新版本的过程。您可以通过单个命令轻松升级或降级 Node.js 版本,确保您的开发环境保持最新。

用于管理 Node.js 版本的 NVM 提供了一个简化和高效的流程,提高了生产力,并减少了项目之间的潜在冲突。它是同时处理多个 Node.js 项目的开发者的必备工具。

根据 NVM 的优势,我们将在不同的平台上通过 NVM 安装 Node.js。

让我们先安装 NVM,然后通过 NVM 安装 Node.js。打开终端/控制台或 cmd,并按照以下三个步骤操作:

  1. 使用最新版本的软件包更新您的系统。

    $ sudo apt-get update

  2. 使用以下命令下载和安装 NVM:

    $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash

  3. 在运行上述命令之前,请确保系统已安装 curl。如果没有安装,请运行以下命令进行安装和验证:

    $ sudo apt install curl $ curl –version

  4. 验证 NVM 版本:

    $ nvm –version

  5. 按以下方式安装 Node.js:

    $ nvm install node

    此命令将安装最新的稳定版 node。

  6. 要安装 LTS 版本的 Node.js,使用此命令:

    $ nvm install -lts

  7. 如果有人需要特定的 node 版本,那么在 NVM 的末尾添加特定版本,并按以下方式安装:

    $ nvm install 18.15.0

    或者

    $ nvm install 18.x

    在前面的命令中,18.15.0 是 node.js 的一个特定版本,18.x 表示它将考虑 18 以上和 19 以下的最高版本。

  8. 按以下方式验证 Node.js 版本:

    $ node –version

在成功执行上述步骤后,您可以在命令提示符中看到以下输出,供您参考。

图 1.3: Linux Node.js 安装

在安装 Node.js 后,默认情况下,NPM 也会随着 Node.js 安装包一起安装,可以使用 $ npm –version 进行验证。

其他对开发者有帮助的 NVM 命令,可用于在不同项目中玩转不同的 node 版本如下:

  • $ nvm ls - 检查系统中 node 版本的列表

  • $ nvm use 18.x – 在项目中特定使用 node 版本

  • $ nvm alias default 18.x – 这是为了设置系统中所有项目的默认版本

  • $ nvm uninstall 18.x - 这将从系统中卸载 18.x 版本

在 Windows 上安装 Node.js

虽然我们已经涵盖了 Linux 安装,现在让我们继续在 Windows 上进行安装过程。您可以根据以下步骤进行 Windows 安装。

通过命令提示符 (**cmd**) 使用名为 "**nvm-windows**" 的专用工具在 Windows 上通过 NVM 安装 Node.js。以下是使用 **nvm-windows** 通过命令提示符在 Windows 上安装 Node.js 的步骤:

  1. 下载 Windows 版本的 NVM:

    前往 nvm-windows: nvm-windows 的 GitHub 仓库。您可以在 github.com/coreybutler/nvm-windows 上探索更多 Windows 版本的 **nvm**

    从以下链接的发行版部分下载最新安装程序 (.zip 文件):github.com/coreybutler/nvm-windows/releases 这里,我们将下载 **nvm-setup.zip** 文件

    图 1.4: Windows Node.js 下载 Zip 文件

  2. 解压 Zip 文件:

    将下载的 .zip 文件解压到系统上的一个目录。

  3. 为 Windows 安装 **NVM**

    以管理员身份打开命令提示符(右键单击并选择“以管理员身份运行”)。导航到您提取**nvm-windows**文件的目录。

    图 1.5:NVM 选择安装位置

  4. 点击**完成**以完成过程!:完成安装过程

    图 1.6:NVM 完成安装过程

    运行**nvm-setup.zip**可执行文件以启动安装过程。按照屏幕上的说明完成安装。

  5. 验证 NVM 安装:

    以管理员身份关闭并重新打开命令提示符。运行 NVM 版本命令以确保 NVM 已正确安装。

  6. 安装 Node.js:

    一旦安装了 NVM,您就可以使用它来安装 Node.js。要安装特定版本的 Node.js,请使用命令**nvm install <version>**(例如,**nvm install 18.0.0**)。

    安装完成后,您可以使用**nvm use <version>**命令在 Node.js 版本之间切换。

  7. 验证 Node.js 安装:

    运行命令**node -v**以验证 Node.js 已安装且激活了正确的版本。

为 mac 安装 Node.js

在 Mac OS 上安装 Node.js 的过程与 Linux 类似。遵循以下步骤:

  1. 安装 NVM:要安装 NVM,我们只需运行以下命令:

    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash

    请确保**curl**可用。

  2. 使用 NVM 安装 Node.js。如果只想安装最新版本的 Node.js,请运行以下命令:

    nvm install node

    此命令将自动下载并安装最新版本的 Node.js。如果您想安装 LTS(长期支持)版本,请运行此命令:

    nvm install –-lts

    (请注意,在lts之前有两个短横线'-’)。

  3. 通过打开控制台并运行以下命令来验证安装:

    node --version

    这将输出安装的版本,例如:

    v20.0.0

  4. 我们还可以使用此命令检查 npm 版本:

    npm -- version

    9.6.4

事件驱动机制

Node.js 是一种异步非阻塞事件驱动编程。任何发生的行为称为事件,它要么由用户执行,要么由系统本身执行。Node.js 提供了一个内置的模块 Event,它是**EventEmitter**的一个实例。事件是一个 I/O 请求,首先发送到事件队列。如果有多个并发请求进入队列,则队列将其传递给事件循环。

图 1.7:事件驱动图

事件循环监控事件队列,从其中收集事件,然后根据阻塞和非阻塞函数进行处理和执行。阻塞函数依次执行,一个接一个,第二个函数只有在第一个函数响应后才会调用。有时它依赖于外部资源,需要等待较长时间才能得到响应,而非阻塞函数不需要等待任何响应。它们异步执行,这意味着一次可以并行运行多个函数,这样它们就不会相互依赖。阻塞和非阻塞函数分别将线程和 I/O 池发送到其池中。一旦实际操作完成,该请求的响应将通过事件循环发送回事件队列。简而言之,事件通过队列发出,然后通过事件循环监控的队列进行注册或注销,相应地绑定适当的处理器。

Node.js 虽然只有一个线程同时处理多个请求,但它遵循非阻塞异步模型,不会阻塞其各自的调用处理器。

事件编程示例

创建一个名为 **event_index.js** 的文件,并将以下代码粘贴到其中:

// 导入 'events' 模块

const events = require('events');

// 初始化一个 EventEmitter 对象

const eventEmitter = new events.EventEmitter();

// 绑定发送消息的事件处理器

eventEmitter.on('send_message', function () {

console.log('Hi, This is my first message');

});

// 与连接事件关联的处理程序

const connectHandler = function connected() {

console.log('Connection is created');

// 触发相应的事件

eventEmitter.emit('send_message');

};

// 将事件与处理程序绑定

eventEmitter.on('connection', connectHandler);

// 触发连接事件

eventEmitter.emit('connection');

console.log("Finish");

使用 **$ node event_index.js** 运行文件,以下输出将显示:

Connection is created

Hi, This is my first message

Finish

同步代码示例

创建一个名为 hello.txt 的文件,并将以下文本粘贴到其中:

Hello, I am Developer

在同一文件夹内创建另一个名为 index.js 的文件,粘贴以下代码并保存:

const fs = require('fs');

console.log('Start');

const data = fs.readFileSync('hello.txt');

console.log(data.toString());

console.log('End');

按以下方式运行代码:

**$ node sync_index.js**

你将得到以下输出:

Start

Hello, I am Developer

End

在这里,fs 是一个文件系统模块,导入它,**fs.readFileSync()** 是一个同步函数,它等待文件读取完成,并将响应分配给 data 变量。它逐行打印并同步执行。

异步代码示例

创建一个名为 **hello.txt** 的文件,并将以下文本粘贴到其中:

Hello, I am Developer

在同一文件夹内创建另一个名为 **index.js** 的文件,粘贴以下代码并保存:

const fs = require('fs');

console.log('Start');

fs.readFile('hello.txt', function (err, data) {

if (err) {

return console.error(err);

}

console.log(data.toString());

});

console.log('End');

按以下方式运行代码:

$ node async_index.js

你将得到以下输出:

Start

End

Hello, I am Developer

在这里,fs 是一个文件系统模块。我们导入它,**fs.readFile()** 是一个异步函数。这个函数不会等待文件读取完成。相反,它有一个回调函数。一旦文件读取操作完成,**callback** 函数就会执行并打印数据。因此,回调函数之后的行是异步执行的。

Node.js 架构类型

当我们开始使用 Node.js 开发应用程序时,决定你的应用程序应该如何结构是很重要的。有几种不同的方式来结构你的 Node.js 应用程序,使用不同的架构。让我们在这里简要讨论一下。

单体架构

在这个架构中,所有业务逻辑的组件或模块都融合在一个单一单元中。几乎所有网络服务器或服务器端框架都是使用单体架构(参见图 1.8),这是对开发者来说最简单的方式:

图片 1.8

图 1.8:单体架构

对于不需要广泛可扩展性的小型应用程序,这种架构可能是合适的。然而,它可能不适合更大和更复杂的应用程序。随着你的服务器端应用程序的流量负载增长,你需要对其进行扩展以处理增加的需求。在这个架构中,你有一个单一的 Node.js 主服务器文件,它将所有 API 请求路由到控制器和服务,管理数据库事务。

你可以使用集群来扩展单体架构以减少负载。然而,在某些情况下,单个服务器无法处理传入的流量。在这种情况下,你可以在多个服务器上部署相同的代码,运行应用程序服务器,并使用像 Nginx 这样的负载均衡器。负载均衡器使用轮询方法,成为了一种可靠的解决方案,特别是对于非常大和高度使用的应用程序。我们将在部署部分更详细地探讨这个方面。这种结构的最大缺点是,如果任何组件需要小的更改,那么需要在所有服务器上执行更改,然后重新构建和重新部署。

微服务架构

微服务架构是一种作为服务集合开发的架构。这里提供的框架允许我们开发、部署微服务,并独立维护它们。微服务通过将应用程序从整体分解成几个较小的部分来解决单体系统的挑战。它是可靠且适合大型和复杂应用程序的,例如电子商务平台、社交网站,这些网站同时向数百万用户提供多个功能。因此,在维护或添加新功能时,它不会中断其他现有功能,只部署更新的服务。如今,由于其灵活性,它越来越受欢迎,多个开发者可以独立工作,只需负责他们自己的小段代码,而不是整个系统代码。

在这种架构中,所有业务逻辑的组件或模块都是独立的。许多大型企业使用这种类型的微服务架构(参见图 1.9):

图片 1.9

图 1.9:微服务架构

根据前面的图示,客户端作为用户或 UI 发送请求,该请求被 API 网关收集并传递给相应的微服务,每个微服务都有自己的功能(Lambda 函数)。此函数连接到数据库,并根据情况返回响应。每个微服务都可以轻松更改和部署,而不会相互影响。此外,这些微服务还通过 API HTTP 服务或 gRPC(谷歌远程过程调用)相互调用,这是微服务架构的通用流程。然而,虽然这种架构在开发中具有成本效益和时间节省的优势,但它可能不适合小型应用程序。这是因为它依赖于基于云的解决方案,即使是最基本的设置也可能变得昂贵。通过采用更经济实惠的解决方案,这种成本问题通常可以得到缓解,这些解决方案由单体架构提供。实际上,微服务代表了在云计算领域利用无服务器架构的一种方式。

无服务器架构

无服务器架构是开发和构建应用程序而不管理基础设施的方法。基本上,任何应用程序都是在一个特定的服务器上开发和部署的。然而,管理托管过程对于开发者来说可能是一项繁重的任务。这就是无服务器架构对于那些想要避免服务器管理并且只为其使用的部分付费的人来说成为一大福音。在无服务器架构中,所有事情都由云计算提供商(如 AWS、Azure、Google 等)提供的第三方服务处理。这些提供商提供各自的功能,如 AWS Lambda 函数、Microsoft Azure 函数和 Google Cloud 函数,因此它也被称为“函数即服务”(FaaS)。然而,需要注意的是,这种方法有其缺点,因为它涉及将一切委托给第三方,这可能会引起安全担忧。尽管它有一些局限性,但它仍然越来越受欢迎,因为组织关注的是实际的产品和服务,而不是基础设施,因此对于那些在基础设施上投入大量精力的人来说,这将更加经济高效。

图 1.10: 无服务器架构

在这种无服务器架构中,一种变革性的应用程序开发和部署方法,消除了对传统服务器管理的需求。相反,它使开发者能够专注于编写代码,而云服务提供商则处理底层基础设施。以下是无服务器概念的视觉表示(见图 1.10)。

它是 AWS 无服务器架构的一个例子,其中使用 AWS API 网关来路由 REST API 调用,并基于与附加网关一起调用的路由 Lambda 函数。Lambda 函数可以用不同的语言编写,但我们认为它们是用具有实际业务逻辑的 Node.js 代码编写的,以执行对数据库的操作。AWS 云提供了各种数据库实例,如 DynamoDB、MySQL、PostgreSQL 等。此外,它可以轻松扩展以适应不断增长的工作负载。AWS 提供自动扩展功能,这意味着当负载激增时,它可以自动添加更多 EC2 实例,而当负载减少时,它可以减少实例。

方面 单体架构 微服务架构 无服务器架构
开发 开发和部署过程更简单 设置复杂,但独立服务促进可扩展性 专注于编写函数而不管理基础设施
可扩展性 由于整个应用程序都需要扩展,因此可扩展性有限 可扩展的,因为每个服务都可以独立扩展 根据需求自动扩展
维护 单一代码库使维护更容易 需要管理多个服务和通信 较少的维护开销;由云服务提供商管理
技术栈 灵活性有限;所有组件使用相同的技术栈 每个服务可以使用不同的技术,具有灵活性 对底层基础设施和运行时的控制有限
部署 部署过程简单;作为一个单一单元部署 由于多个服务,部署复杂 部署过程简化
资源利用率 资源利用率可能效率低下 根据需要扩展服务,实现优化的资源利用率 按需执行,实现高效的资源利用率
成本 初期成本较低;长期运营成本较高 初始设置成本较高;随着规模扩大可能节省成本 对于低流量应用,按使用付费模式可能具有成本效益
错误隔离 一个部分的错误可能影响整个应用程序 错误被隔离到特定的服务中;其他服务不受影响 由云服务提供商管理,可能存在供应商锁定风险
灵活性 由于单体结构导致的灵活性有限 可以使用不同的技术和语言,具有灵活性 对底层基础设施和运行时的控制有限

表 1.1: 架构比较

表 1.1 提供了在 Node.js 中单体、微服务和无服务器架构的各个方面比较,概述了各自的优缺点。根据具体项目需求,一种架构可能比其他架构更适合。

让我们通过编程创建一个基本的 HTTP 和 HTTPS 服务器。

编写 HTTP 服务器

现在当 Node.js 在你的系统中正确设置并运行时,让我们通过 HTTP 服务器来执行著名的 "**Hello World**"。

让我们创建一个名为 index.js 的文件,并将以下代码复制到其中:

const http = require('http');

const hostname = '127.0.0.1';

const port = 3000;

const server = http.createServer((req, res) => {

res.statusCode = 200;

res.setHeader('Content-Type', 'text/plain');

res.end('Hello World');

});

server.listen(port, hostname, () => {

console.log(`Server running at http://${hostname}:${port}/`);

});

在提供的代码中,"**http**" 是 Node.js 默认提供的模块,因此无需单独安装。然而,对于 Node.js 内置模块之外的模块,你可以从 npm 库中安装它们。

npm 库包含数百万个注册的包,你可以使用以下代码进行安装:

$ npm install package-name

ex. npm install express

包名可以是 "**body-parser**", "**express**", "**moment**" 等等。

http 模块创建了一个运行在特定端口 3000 上的 http 服务器。理想情况下,Node.js 应该运行在端口 3000 上,但开发者可以分配不同的端口,如 3001、4000 或任何端口。只需确保端口号不与其他系统上的应用程序冲突即可。

要运行程序,打开源代码目录的控制台,粘贴以下命令:

$ node index.js

一旦运行,打开浏览器并访问 URL http://localhost:3000;它将打印"**Hello World**"。

图 1.11:HTTP 服务器程序输出

使其成为 HTTPS

我们刚刚创建的服务器不提供安全的服务 API 的方式。通常,我们需要使用 HTTPS 而不是 HTTP 来服务 API。

HTTP 不加密数据,因此在传输过程中信息泄露时并不安全。另一方面,HTTPS 在客户端到服务器的请求期间加密数据,使其变得安全。

让我们用 https 服务器重写相同的代码。对于 HTTPS,我们需要 SSL 证书。

首先,让我们创建一个自签名的 SSL 证书:

  1. 打开控制台,如果系统未安装**openssl**,则安装。

  2. 对于 Debian Linux(如 ubuntu),可以使用**apt**命令进行安装:

    $ sudo apt install openssl

  3. 对于 Centos、Fedora 和 Rocky Linux,可以使用 yum 来安装**openssl**

    $ sudo yum install openssl

  4. 按照以下方式将**openssl**目录设置为:

    $mkdir openssl

    $cd openssl

  5. 使用以下命令请求生成**ssl**证书:

    $ openssl req -newkey rsa -x509 -sha256 -days 365 -nodes -out ssl.crt -keyout ssl.key

    让我们了解前面的命令:

    • **-newkey rsa**:使用**rsa**算法创建新密钥,默认 2048 位

    • **-x509**:创建 X.509 证书

    • **-sha256**:使用 256 位 SHA(安全散列算法)

    • **-days 365**:证书的有效期为 365 天。您可以使用任何正整数

    • **-nodes**:创建不带密码的密钥。

    • **-out ssl.crt**:指定要写入新创建证书的文件名。您可以指定任何文件名。

    • **-keyout ssl.key**:指定要写入新创建的私钥的文件名。您可以指定任何文件名。

一旦输入此命令,它将提示以下问题(如图图 1.12所示)。按回车键直到完成,并检查包含**ssl.crt****ssl.key**文件的文件夹:

图 1.12:OpenSSL 证书生成

现在创建**main.js**,并写入以下代码以创建**https 服务器**,端口为**3000**

// 导入 https 模块

const https = require(`https`);

//导入 fs 模块以读取文件

const fs = require(`fs`);

const options = {

key: fs.readFileSync(`./openssl/ssl.key`),

cert: fs.readFileSync(`./openssl/ssl.crt`)

};

// 在端口 3000 上创建 https 服务器

https.createServer(options, (req, res) => {

res.writeHead(200);

res.end(`hello world from https server \n`);

}).listen(3000);

使用 node main.js 运行代码,并在浏览器中打开 https://localhost:3000,它将显示"**来自 https 服务器的 hello world**"。这样,就使用非常基础的示例构建了一个安全的 https 服务器。

使用集群模块

Node.js 可以通过集群模块轻松地使应用程序高度可扩展。集群通过创建子进程来提高另一个进程,从而将单线程分割成多线程。由于这一点,高流量负载被减少并分配到具有相同端口的线程的不同实例上。它是 Node.js 的内置模块。由于 Node.js 支持异步单线程,有时当阻塞函数更多时,应用程序性能会下降。集群对于提高性能来说是最重要和最有用的。

Node.js 服务器启动多个传入请求。首先,它指向主进程,也称为主进程或主节点,这是一个单一的进程。之后,它从父进程中分裂出不同的子进程。子进程被称为工作进程,可以有多个,并且拥有自己的事件循环来同时处理它。集群有两种进程分配方式。第一种是默认的轮询方法,其中主节点监听服务器请求并以等圆周顺序将它们发送到工作进程,另一种是基于套接字的方式,其中主节点监听并仅分配想要执行该进程的感兴趣的工作进程。

集群架构利用多个相互连接的服务器的功能,提高了当代应用程序的性能、可靠性和可扩展性。图 1.13展示了集群节点如何无缝协作,高效地管理传入请求。

图 1.13: 集群图

无集群模块编程示例

创建一个名为**without_cluster.js**的文件,并保存以下代码:

//导入 http 模块以创建服务器

const http = require('http');

// 创建服务器和测试 API

http.createServer(function (req, res) {

if (req.url === "/api/test" && req.method === "GET") {

console.time('API_without_cluster');

let result = 0;

for (let i = 0; i < 5000000; i++) {

result += i;

}

console.timeEnd('API_without_cluster');

console.log(`结果 = ${result} - 在进程 ${process.pid}`);

res.end(`结果 = ${result}`);

}

}).listen(3001);

使用以下命令运行代码:

$ node without_cluster.js

现在我们可以通过在浏览器中使用 URL http://localhost:3001/api/test 来测试它,并通过多次点击刷新按钮连续调用它多次。

以下输出将在控制台显示:

图 1.14: 无集群输出

有集群模块编程示例

现在创建另一个名为**cluster.js**的文件,并保存以下代码:

//导入集群模块

const cluster = require('cluster');

//导入 http 模块以创建服务器

const http = require('http');

//检查是否为主进程,然后通过 fork()方法创建子进程

if (cluster.isMaster) {

const numWorkers = require('os').cpus().length;

console.log(`Master ${process.pid} started`);

console.log(`Number of workers => ${numWorkers}`)

for (var i = 0; i < numWorkers; i++) {

cluster.fork();

}

cluster.on('exit', (worker, code, signal) => {

console.log(`worker ${worker.process.pid} died`);

console.log("Let's fork another worker!");

cluster.fork();

});

} else {

// 它是工作进程,因此使用相同的 3000 端口运行多个进程

console.log(`Worker ${process.pid} started`);

http.createServer(function (req, res) {

if (req.url === "/api/test" && req.method === "GET") {

console.time('API_with_cluster')

let result = 0;

for (let i = 0; i < 5000000; i++) {

result += i;

}

console.timeEnd('API_with_cluster');

console.log(`Result = ${result} - on process ${process.pid}`);

res.end(`Result = ${result}`);

}

}).listen(3000);

}

现在运行以下命令来执行代码:

$ node cluster.js

打开浏览器,输入 URL http://localhost:3000/api/test 并多次调用它。控制台将给出以下输出:

图 1.15:使用集群的输出

如我们所见,当我们使用集群模块时,响应时间在 12 到 16 毫秒之间,而没有使用集群模块时,时间会更高——14 到 22 毫秒。这里的差异不大,因为我们使用的代码几乎没有任何逻辑、数据库操作或其他 IO。时间可能会随着实现而变化,因此集群在计算量大时很有用,但如果计算量不是很大,可能就不太有益。基本上,集群允许我们运行多个工作进程,这些进程可以利用多个 CPU。

集群模块还可以用来设置主从结构,其中主进程监控从进程,如果从进程停止或崩溃,主进程可以启动另一个从进程。这样,我们可以在应用程序中安全地处理错误,并防止应用程序完全崩溃。在第四章,规划应用程序中,我们将看到它的实际应用。

结论

在本章中,我们介绍了 Node.js 以及它提供的功能及其优缺点。我们学习了如何安装 Node.js 并创建了一个简单的服务器。我们还熟悉了 Node.js 如何使用事件循环和不同的架构类型。后来,我们使用 HTTP 和 HTTPS 创建了一个网络服务器。最后,我们看到了如何使用集群模块。

在本章中,我们使用了 JavaScript 作为编程语言,当项目规模变大时,它是不易维护的。更好的方法是使用 TypeScript 而不是 JavaScript。在下一章,我们将学习 TypeScript 的基础知识。

多项选择题

  1. Node.js 是什么,以下关于它的哪个陈述是正确的?

    1. Node.js 是一个封闭源代码的 JavaScript 运行时环境

    2. Node.js 只能在 Windows 操作系统上使用

    3. Node.js 主要基于 Python 代码

    4. Node.js 是一个开源的 JavaScript 运行时环境,可以在各种操作系统上使用

  2. Node.js 通常用于哪些类型的应用?

    1. Node.js 主要用于桌面应用和游戏

    2. Node.js 主要用于移动应用开发

    3. Node.js 通常用于单页应用(SPAs)、实时应用、物联网(IoT)设备应用和数据流应用

    4. Node.js 仅用于基于 Web 的电子邮件服务,如 Gmail

  3. 使用 Node.js 进行实时应用的关键优势之一是什么?

    1. Node.js 是构建实时应用的唯一选择

    2. Node.js 为实时应用提供图形用户界面

    3. Node.js 通过 WebSockets 提供持续连接,从而实现更快的响应时间

    4. Node.js 只能用于音频和视频流应用

  4. 如何检查系统上安装的 Node.js 版本?

    1. 在终端运行命令 **node version**

    2. 在终端运行命令 **node info**

    3. 在终端运行命令 **node --v**

    4. 在终端运行命令 **node -v**

  5. Node.js 事件循环的关键特征是什么?

    1. 它通过并行执行阻塞函数来提高性能

    2. 它在移动到下一个操作之前等待所有函数完成

    3. 它在 Node.js 应用程序中处理渲染和用户界面任务

    4. 它管理异步操作,确保非阻塞执行

  6. 微服务架构与单体架构有何不同?

    1. 微服务使用单一代码库的所有组件

    2. 微服务紧密耦合,作为一个单一的应用程序运行

    3. 微服务松散耦合,由独立可部署的服务组成

    4. 微服务仅通过 RESTful API 进行通信

  7. 何时无服务器架构是应用开发的合适选择?

    1. 当你想要专注于编写代码而不必担心服务器配置时

    2. 当应用程序有一个单体代码库时

    3. 当你想最小化开发成本时

    4. 当你需要完全控制服务器管理时

  8. HTTP 模块中哪个方法用于在 Node.js 中创建 HTTP 服务器?

    1. **http.createServer()**

    2. **http.request()**

    3. **http.get()**

    4. **http.post()**

  9. 使用 Cluster 模块创建 Node.js 进程集群的方法是哪个?

    1. **cluster.start()**

    2. **cluster.fork()**

    3. **cluster.create()**

    4. **cluster.spawn()**

  10. Cluster 模块如何增强 Node.js 应用程序的性能?

    1. 通过创建 Node.js 应用程序的多个实例

    2. 通过更有效地管理数据库连接

    3. 通过减少可用的 CPU 核心数

    4. 通过减慢应用程序的响应时间

答案

  1. d

  2. c

  3. c

  4. d

  5. d

  6. c

  7. d

  8. a

  9. b

  10. a

进一步阅读

nodejs.org/en

第二章

TypeScript 介绍

介绍

在 JavaScript 中开发的应用程序数以百万计,但开发者在开发过程中面临许多问题。JavaScript 直接运行而不先编译代码。由于动态类型和缺少编译时检查,运行时错误、代码复杂性和大型代码库的维护等问题很常见。TypeScript 作为 JavaScript 的超集被引入,以解决所有这些问题。

在本章中,我们将讨论 TypeScript 的基本概念以及如何在开发应用程序时使用 TypeScript 代替 JavaScript。

结构

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

  • TypeScript 概述

  • TypeScript 的优势

  • TypeScript 的陷阱

  • 安装 TypeScript

  • 使用 TypeScript 构建基本应用程序

  • 面向对象编程(OOP)概念

  • ECMAScript 特性

  • EsLint

TypeScript 概述

TypeScript 是 JavaScript 的超集,这意味着所有 JavaScript 特性都存在,并且还包含其他额外特性。JavaScript 是一种基于对象的语言,但它不提供面向对象编程的所有概念,所以缺失的部分通过 TypeScript 来实现。TypeScript 是由微软开发和维护的开源编程语言。它于 2012 年发布,通常缩写为 TS。

在图像中显示的是 TypeScript 的表示,它作为 JavaScript 的超集,由 TypeScript 编译器(TSC)编译,然后转换为 JavaScript 代码。

图 2.1:TypeScript 作为 JavaScript 的超集

JavaScript 是一种解释型语言,它直接运行而不需要先编译代码。因此,如果发生任何错误,它将在运行时被检测到。TypeScript 可以轻松处理这个问题,因为它在检查数据类型时非常严格。如果使用得当,运行时出现错误的几率会减少。TypeScript 首先编译代码,然后转换代码。基本上,这里的转换意味着将 TypeScript 代码转换为 JavaScript 代码。它的默认编译器被称为**tsc**。TypeScript 支持所有 ES6(ECMAScript6)特性和面向对象的概念。TypeScript 是静态检查,这意味着它在执行代码之前检测错误。

让我们考虑一个例子:

JS 代码

let name = "Jack";

name = 12;

无错误

TS 代码

let name: string = "Jack";

name = 12;

错误:类型'number'不能分配给类型'string'

这是一个 JS 和 TS 代码的非常基础的例子。区别在于 JS 会自动考虑字符串类型,一旦分配了数字值,它就会将数字转换为字符串,因此不会产生任何错误。然而,当在涉及数字相关操作的后续代码部分中使用时,这可能会导致运行时问题。相比之下,TypeScript 在构建过程中识别此类问题,在该阶段生成错误。

TypeScript 的优点

使用 TypeScript 有许多好处。以下是一些关键优点:

  • 开源: TypeScript 是免费且开源的强类型检查编程语言。它可以免费轻松安装和使用。

  • 跨平台: TypeScript 支持跨平台和跨浏览器兼容性。毕竟,一旦编译完成,它作为 JavaScript 代码运行。

  • 支持 OOP 和 ES6: TypeScript 具有使用最强大的面向对象编程概念的能力,就像其他高级 OOP 语言(如 JAVA)一样,它为 Js 启用了这些功能。它支持类、接口、访问修饰符、抽象、继承等。它还具有使用 ES6 功能的能力,以实现更好的编码标准和可读性。

  • 错误检测器: TypeScript 在开发应用程序时提供错误检测功能,在运行之前提供错误描述;因此,开发者可以确保节省调试时间,并使用健壮的代码进行开发。

  • 可选类型检查: TypeScript 基本上是用于强类型检查的,但它可以是可选的,因为它允许静态以及动态检查。对于动态检查,它需要是可选的。

  • 灵活性和可维护性: TypeScript 具有巨大的灵活性,可以与不同的 JavaScript 框架(如 React.js、Angular、Nest.js、Express.js、loopback 等)一起使用。它可以在客户端和服务器端都作为 JavaScript 使用。此外,即使不同的开发者遵循标准编码实践协作并共同工作,代码也易于维护。

  • 更好的 IDE 支持: TypeScript 比 JavaScript 提供了更好的 IDE 支持。所有流行的 IDE 都提供了代码补全、参数提示、类型检查等功能,这使得开发更快、更高效。

TypeScript 的陷阱

虽然 TypeScript 有多个优点,但也存在一些缺点:

  • 不适合小型应用: TypeScript 并不适合所有应用,尤其是小型应用,因为它在不需要类型检查的简单功能实现上变得复杂,而在 JS 中则可以轻松完成。

  • 编译时间: TypeScript 代码首先进行编译,因此在整个转换过程中需要时间,这需要编译器进行编译,而 JavaScript 不需要。

  • 学习曲线:由于比 JavaScript 复杂一些,开发者需要投入一些时间来学习和适应语法。然而,这通常只发生在第一次。一旦开发者熟悉并适应了 TypeScript,由于 TypeScript 的优势,可以节省大量时间。

安装 TypeScript

TypeScript 可以通过两种方式在您的系统中安装和设置。第一种是全局安装,另一种是将它作为开发依赖项本地安装。我们可以通过在系统中预先安装的 npm 模块来安装它。

全局安装

当处理多个项目时,全局安装方法证明是有益的,因为它消除了对特定项目安装的需求。然而,它假设在各个项目中使用的是相同的版本。

打开终端并粘贴以下命令。它将在您的系统中安装最新版本。

$ npm install typescript -g

安装完成后,如果您想验证已安装 TypeScript 的版本,请粘贴以下代码:

$ tsc -v

前一个命令的输出将类似于 "**版本 5.3.3**"

图 2.2:TypeScript 安装

按项目安装

在许多情况下,需要使用不同版本的 TypeScript 来处理不同的项目,这时本地安装是必要的。

为项目创建一个文件夹,并按照以下方式本地安装:

$ mkdir basic-typescript-project

$ cd  basic-typescript-project

$ npm install typescript  --save-dev

这将在该项目中本地安装 TypeScript。现在我们可以开始使用 TypeScript 开发基本应用程序了。

使用 TypeScript 构建基本应用程序

安装完成后,转到创建的项目根目录,并按照以下步骤操作:

  1. 初始化项目:输入以下命令将创建一个 **package.json** 文件以初始化 Node.js 项目,并会提示一些关于项目的信息,如项目名称、版本、描述、入口点(主文件)等。因此,按照如图 图 2.2 所示输入这些详细信息。

    $ npm init

    图 2.3:项目初始化步骤

  2. 将 TypeScript 作为开发依赖项安装:现在运行以下命令,这将添加 TypeScript 作为开发依赖项(开发依赖):

    $ npm install typescript --save-dev

  3. 将 node 作为 TypeScript 的开发依赖项安装:在处理 Node.js 项目时,最好使用类型安装它。这将允许使用 TypeScript 使用 Node.js 的所有默认模块,例如 http、https、fs 等。

    $ npm install @types/node --save-dev

    在开发过程中,可以使用 **@types/pkg_name --save-dev** 安装任何额外的包,以便在 IDE 中提供类型定义。

  4. 创建一个 **tsconfig.json** 文件并定义编译器选项:在根目录中创建一个 **tsconfig.json** 文件,然后按如下方式配置编译器选项:

    {

    "compilerOptions": {

    "target": "es2019",

    "module": "commonjs",

    "moduleResolution": "node",

    "pretty": true,

    "sourceMap": true,

    "outDir": "./dist",

    "baseUrl": "./lib",

    "noImplicitAny": false,

    "esModuleInterop": true,

    "removeComments": true,

    "preserveConstEnums": true,

    "experimentalDecorators": true,

    "alwaysStrict": true,

    "forceConsistentCasingInFileNames": true,

    "emitDecoratorMetadata": true,

    "resolveJsonModule": true,

    "skipLibCheck": true

    },

    "include": [

    "lib/**/*.ts"

    ],

    "exclude": [

    "node_modules"

    ],

    "files": [

    "node_modules/@types/node/index.d.ts"

    ]

    }

    让我们逐个了解这些编译器选项,然后排除和包含 **tsconfig.json** 文件中的选项:

    • **target**: 指定 ECMAScript 目标版本。默认为 **'ES3'**,可以设置为 **'ES5'****'ES6'****'ES2015'****'ES2016'****'ES2017'****'ES2018'****'ES2019'****'ESNEXT'**。它设置输出 JavaScript 的 JavaScript 语言版本,并包含兼容的库声明。

    • **module**: 指定生成的模块代码。如果目标属性是 **ES5****ES3**,则默认值为 **'commonjs'**,否则为 **'ES6'****'AMD'****'System'****'ES2015'****'ESNEXT'**

    • **moduleResolution**: 指定模块解析策略为 **'node'**(Node.js)或 **'classic'**(TypeScript)。对于现代 JS,它始终设置为 node,否则为 classic。它指定 TypeScript 如何根据给定的模块指定符查找文件。

    • **pretty**: 启用输出中的颜色和格式化,以便更容易阅读编译器错误。这可以设置为 truefalse

    • **sourceMap**: 这可以启用为发出的 JavaScript 生成源映射文件,也可以不生成,它有助于调试和错误报告。

    • **outDir**: 指定一个输出文件夹以存储所有转换后的文件。

    • **baseUrl**: 指定基本目录以解析非相对模块名称,其中所有 TypeScript 文件都位于该目录。

    • **noImplicitAny**: 启用对具有隐含 any 类型的表达式和声明的错误报告。

    • **esModuleInterop**: 发出额外的 JavaScript 以简化对 CommonJS 模块的导入支持。这启用了 **allowSyntheticDefaultImports** 以实现类型兼容性。

    • **removeComments**: 禁用输出注释,这意味着不会向输出中发出注释。

    • **preserveConstEnums**: 禁用生成代码中删除 **enum** 声明。当设置为 true 时,**enum** 在运行时存在,并提供了一种通过发出枚举值而不是引用来减少应用程序运行时整体内存占用空间的方法。

    • **experimentalDecorators**: 此选项启用在 TS 项目中使用装饰器。ES 尚未引入装饰器,因此默认情况下是禁用的。

    • **alwaysStrict**: 确保您的文件以**ECMAScript**严格模式解析,并为每个源文件发出"**use strict**"。默认值是 false。

    • **forceConsistentCasingInFileNames**: 确保导入中的大小写正确,不允许对同一文件的不一致大小写引用。

    • **emitDecoratorMetadata**: 启用对发射装饰器类型元数据的实验性支持。

    • **resolveJsonModule**: 启用导入**.json**文件。

    • **skipLibCheck**: 跳过所有**.d.ts**文件的类型检查。

    • **include**: 指定匹配要包含在编译中的文件的 glob 模式列表。如果**tsconfig.json**中没有‘files’**‘include’**属性,编译器默认包含包含目录及其子目录中的所有文件,但排除由‘exclude’**指定的文件。

    • **exclude**: 指定要排除的文件列表。**'exclude'**属性仅影响通过‘include’**属性包含的文件,不影响**'files'**属性。

    • **files**: 如果tsconfig.json中没有**'files'****'include'**属性,编译器默认包含包含目录及其子目录中的所有文件,但排除由**'exclude'**指定的文件。当指定了**'files'**属性时,只包含那些文件和由‘include’**指定的文件。

  5. 使用 TypeScript 创建基本应用程序:创建一个**lib**文件夹,并在其中创建**main.ts**文件,如**tsconfig.json**中定义的,包含**lib**文件夹,它编译所有.ts 文件。

    $ mkdir lib

    $ cd lib

    $ touch main.ts

    现在在main.ts文件中写下以下代码并保存:

    import * as http from 'http';

    const hostname = '127.0.0.1';

    const port = 3000;

    const server = http.createServer((req, res) => {

    res.statusCode = 200;

    res.setHeader('Content-Type', 'text/plain');

    res.end('Hello World');

    });

    server.listen(port, hostname, () => {

    ``console.log(Server running with TypeScript project at

    http://${hostname}:${port}/`);

    });

  6. 运行 TypeScript 代码:安装**ts-node**包进行编译和**nodemon**,以便每次文件更改时都可以自动重新加载或重新运行。这样我们就不需要在某些更改后再次运行服务器。转到根目录并输入以下命令:

    $ npm install ts-node nodemon  --save-dev

    $ tsc

    一旦运行了**tsc**命令,就会创建一个名为**dist**的新目录(我们在**tsconfig.json**中将其指定为 outDir)。所有 TS 文件都会编译成 JS 并放置在这个目录中。现在我们可以在指定的端口上运行应用程序,默认通常是 3000。如果端口是 3000,那么应用程序将可通过 http://127.0.0.1:3000 访问。

    **nodemon** 可以检测 **dist** 目录中的新更改,并在有更改时立即重启服务器。我们只需使用 **nodemon** 而不是 node 来运行服务器:

    $ nodemon dist/main.js

    图片 2.4

    图 2.4:编译 TypeScript 应用程序

    **nodemon** 可以检测 **dist** 目录中的更改并重新运行 **main.js**,但由于我们正在用 TS 编码,每次更改后都需要重新编译文件。使用 **tsc** 进行手动重新编译也是一个繁琐的任务。为了解决这个问题,我们可以使用监视选项,这样每次 TS 文件更改时,它都会自动编译。要使用监视模式运行 **tsc**,我们可以使用 **-w** 标志,如下所示:

    $ tsc -w

这样我们就可以构建一个基本的 TypeScript 应用程序。如果你想要了解更多关于 **tsconfig.js** 编译器选项的信息,可以访问 www.TypeScriptlang.org/tsconfig 以获取参考。

我们已经看到了构建基本的 TypeScript 应用程序,现在我们将探讨 JavaScript 中不支持的对象导向概念。

TypeScript 中的面向对象概念

TypeScript 支持面向对象编程,我们将学习关于类、接口、抽象、继承、封装和多态。然而,在那之前,我们需要了解 TypeScript 中的数据类型。因此,让我们看看支持的数据类型。

数据类型

TypeScript 有原始数据类型和非原始数据类型。原始数据类型意味着值直接赋值,也称为基本数据类型,而非原始数据类型可以是原始数据类型的引用或子类型。

原始数据类型

对于原始数据类型,值是直接赋值的。这些也称为基本数据类型:

  • **String**: 它用于文本数据,这些数据被双引号或单引号包围。例如:

    let name:string = "Tom";

    name = "Jerry";

  • **Number**: 它用于数值数据。它还支持十进制、二进制、十六进制和八进制数据。例如:

    let total :number = 100;

    let binaryNumber :number = 0b0101;

    let hexaNumber :number = 0xf00d;

    let octalNumber :number = 0o555;

  • **Bigint**: 它用于数值数据。它还支持大整数。例如:

    let bigNumber :bigint = 200n;

    let anotherBigNumber :bigint = BigInt(200);

  • **Boolean**: 当你需要 **true****false** 的情况时使用。例如:

    let mute :boolean = true;

    mute = false;

  • **Null****Undefined**: 它与 JavaScript 相同:null 表示一个没有任何对象的引用,而 undefined 表示类型尚未初始化。两者都是其他类型的子类型。

    let name:undefined = undefined;

    let num:null = null;

  • **Symbol**: 它是**ES6**中引入的一种新数据类型。它通过**Symbol**函数创建一个唯一的符号。

    let symbolTest  = Symbol();

非原始数据类型

  • **对象**: 它表示一个键值对,其中键是属性,值可以是任何原始类型。例如:

    let student :Object = {id: 1, name :"Jack"}; //声明和赋值

    let employee :{emp_id:number, name:string};  //声明

    employee = {emp_id: 2, name :"Jack"};  //赋值

  • **数组**: 它类似于 JavaScript 数组,可以在单个变量中存储多个元素。它可以有两种写法:

    let colors: string[ ] = ["Black","white","Red"];

    let colors: Array<string>  =  ["Black","white","Red"];

    第一种定义方式是使用元素类型后跟 [ ](方括号)。第二种方式是使用 Array 后跟 **<数组中元素的类型>**

  • **枚举**: 当可能的值来自一组少数固定常量值时使用枚举。它可以是有数字 **枚举**,字符串 **枚举**,异构 **枚举**,或任何原始类型 **枚举****枚举**的一个例子可以是任务的状况:

    enum Status {

    "pending",

    "in_progress",

    "completed"

    }

  • **元组**: 它表示一个具有已知数据类型的固定大小数组。例如,

    //正确值需要匹配

    let tupleExample : [string,number] = ["Jack",1]

    //gives error

    let tupleExampleIncorrect : [string,number] =[1,"Jack"];

    在这个例子中,第一个元素应该是一个字符串,第二个应该是一个数字。如果提供错误的顺序,将会抛出错误。

  • **任意类型**: 当值类型未知时使用特殊类型。简单来说,指定任何数据类型后,不会进行检查,也不会给出任何错误。除非没有其他选择,否则不建议使用它。

    let data: **any** = {};

    data.geometry={

    "type": "Polygon",

    "coordinates": [

    [

    [123.61, -22.14], [122.38, -21.73], [121.06, -21.69]

    `]` `]` `}` `data.latitude = 90;` `data.longitude = 90;`

* `**自定义类型**`: 它允许你使用原始类型和非原始类型来定义自己的自定义类型。例如,一个类型 project 可以这样创建: `type project = {` `name: string;` `description: string;` `start_time: number;` `owner_name: string[];` `};` `const projectData: project = {` `name: 'Project Management',` `description: 'It is about to manage any project from on board to deployment process',` `start_time: 1682516674343,` `owner_name: ["Jack", "John"]` `};`

js` We have seen most used data types, but there are some other data types such as union, unknown, void, never, functions, and so on, which are used rarely. If you want to explore more about it, then click on this official reference **TypeScript Types** ([`www.typescriptlang.org/docs/handbook/2/everyday-types.html`](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html)). Now when we know about the data types offered, let us jump to Object Oriented Programming with TypeScript. # Class Class is a collection of objects which has some common properties that works as components which can be reusable. In simple terms, it is a blueprint of objects. JavaScript supports class after `**ES2015**`, so TypeScript is also allowed to use it. Class basically contains objects, constructors, and methods. Let us take an example of a class as follows: `export class Book {` `private title: string;` `private author: string;` `private price: number;` `constructor(title: string, author: string, price: number) {` `this.title = title;` `this.author = author;` `this.price = price;` `}` `// Getter method for title` `getTitle(): string {` `return this.title;` `}` `// Setter method for title` `setTitle(value: string) {` `this.title = value;` `}` `// Getter method for author` `getAuthor(): string {` `return this.author;` `}` `// Setter method for author` `setAuthor(value: string) {` `this.author = value;` `}` `// Getter method for price` `getPrice(): number {` `return this.price;` `}` `// Setter method for price` `setPrice(value: number) {` `this.price = value;` `}` `displayInfo() {` ``console.log(`Title: ${this.title}`);`` ``console.log(`Author: ${this.author}`);`` ``console.log(`Price: $${this.price}`);`` `}` `}` `// Create an instance of the Book class` `const myBook = new Book('The Great Gatsby', 'F. Scott Fitzgerald', 15.99);` `// Use getter and setter methods to update book information` `myBook.title = 'To Kill a Mockingbird';` `myBook.author = 'Harper Lee';` `myBook.price = 1200.99;` `// Display updated book information` `myBook.displayInfo();` Here, we have defined a class named Book. Keeping the first letter of class name as capital is a standard practice. We exported that class using keyword **export** so that it can be imported in other files. The class consists of a property called `**title**`, `**author**`, `**price**`, a `**constructor**`, and methods `**get**` and `**set**` respective properties. A class object is created with a `**new**` keyword and followed by the class name as function and pass argument for required constructor defined. For example, `**new Book('The Great Gatsby', 'F. Scott Fitzgerald', 15.99)**`;. In Class, `**constructor**` is called first automatically. In this case, `**constructor**` is setting the property `**name**` with the given values. We can access class properties using `**this**` keyword anywhere inside the class. In this example, each property (`**title**`, `**author**`, and `**price**`) has a corresponding getter and setter method. These methods provide controlled access to the private properties of the Book class. The output of the preceding code would be: `Output :` `Title: To Kill a Mockingbird` `Author: Harper Lee` `Price: $1200.99` # Inheritance TypeScript supports inheritance in which one class can be extended by another class in which all its properties and methods are inherited from base class to derived class. Consider the following example: `import { Book } from './class';` `export class EBook extends Book {` `private format: string;` `constructor(title: string, author: string, price: number, format: string) {` `super(title, author, price);` `this._format = format;` `}` `// Override displayInfo method to include format` `displayInfo() {` `super.displayInfo(); // Call base class method` ``console.log(`Format: ${this._format}`);`` `}` `}` `// Create an instance of the EBook class` `const myEBook = new EBook('The Great Gatsby', 'F. Scott Fitzgerald', 15.99, 'PDF');` `// Display EBook information` `myEBook.displayInfo();` `The output would be as follows:` `Title: To Kill a Mockingbird` `Author: Harper Lee` `Price: $1200.99` `Title: The Great Gatsby` `Author: F. Scott Fitzgerald` `Price: $15.99` `Format: PDF` As per the preceding example, `**EBook**` is a child class of `**Book**` that extends all its properties and methods to derived (`**EBook**`) class. This means that `**EBook**` inherits all properties and methods from `**Book**`. The constructor of `**EBook**` takes additional parameters (`**title**`, `**author**`, `**price**`, and `**format**`) compared to the base class. It calls the constructor of the base class (`**super**`) with the title, author, and price, and initializes the `**_format**` property specific to `**EBook**`. The `**displayInfo**` method is overridden in `**EBook**` to include information about the e-book’s format. The `**super.displayInfo()**` call invokes the `**displayInfo**` method of the base class to display the book’s title, author, and price. Then, it logs the format of the e-book. This code demonstrates how to extend a base class (`**Book**`) to create a subclass (`**EBook**`) with additional properties and behavior, while also leveraging inheritance and method overriding to reuse and customize functionality from the base class. # Access Modifiers TypeScript has four different access modifiers for property(field) and method such as `**public**`, `**private**`, `**protected**`, and `**static**`: * `**Public**`: It is the default access modifier which can be used anywhere; as the name `**public**`, it can be accessed in the same class, child class, or to any other class. `class Car {` `public brand: string;` `constructor(brand: string) {` `this.brand = brand;` `}` `public startEngine() {` ``console.log(`Starting the engine of ${this.brand} car.`);`` `}` `}` `const myCar = new Car('Toyota');` `console.log(myCar.brand); // Accessing public property` `myCar.startEngine();      // Accessing public method` `Output:` `Toyota` `Starting the engine of Toyota car.` * `**Private**`: It is accessible only within the same class. `class BankAccount {` `private balance: number;` `constructor(initialBalance: number) {` `this.balance = initialBalance;` `}` `public deposit(amount: number) {` `this.balance += amount;` `}` `public getBalance() {` `return this.balance; // Accessing private property` `}` `}` `const account = new BankAccount(1000);` `console.log(account.getBalance()); // Accessing public method` `account.deposit(500);             // Accessing public method` `console.log(account.balance);    // Error: Property 'balance' is private` * **Protected**: It can be accessible in the same class and all of the child classes only. In the example, the `**price**` is defined as protected, so it can be called from within the same and child class. `class MyBook {` `// Public properties` `public title: string;` `public author: string;` `protected price: number; // Protected property` `constructor(title: string, author: string, price: number) {` `this.title = title;` `this.author = author;` `this.price = price;` `}` `}` `class EBook extends MyBook {` `private format: string;` `constructor(title: string, author: string, price: number, format: string) {` `super(title, author, price);` `this.format = format;` `}` `public displayInfo() {` ``console.log(`Price: ${this.price}`);`` `}` `}` `// Create an instance of the Book class` `const myBook = new MyBook('The Great Gatsby',` `'F. Scott Fitzgerald', 15.99);` `// console.log(myBook.price); // Error: Property 'price' is protected and only accessible within the class and its subclasses` `// Create an instance of the EBook class` `const myEBook = new EBook('The Great Gatsby',` `'F. Scott Fitzgerald', 15.99, 'PDF');` `// Accessing public properties and method of EBook` `myEBook.displayInfo(); // Accessible` `Output:` `Price: 15.99` * `**Static**`: It can be used directly without creating an object of class. Properties and methods created using `**static**` belong to the class itself rather than to instances of the class. `class User {` `static user_name: string = 'Jack';` `public static calculateWorkingHoursPerMonth(hrsPerDay: number) {` `return hrsPerDay * 30;` `}` `public static setUserName(name: string) {` `User.user_name = name;` `}` `}` `const user = User.user_name;` ``console.log(`Username = ${user}`);`` `const totalHrs = User.calculateWorkingHoursPerMonth(8);` ``console.log(`Total hrs per month = ${totalHrs}`);`` `User.setUserName("Panchal");` ``console.log(`Modified Username = ${User.user_name}`);`` `Output:` `Username = Jack` `Total hrs per month = 240` `Modified Username = Panchal` # Interface Interface is an `**abstract**` and `**static**` type that represents the behavior of a class which just describes the class and not the actual implementation. It is defined by the keyword `**interface**`. For example, an interface `**IUser**` is defined as follows: `interface IUser {` `first_name: string;` `last_name: string;` `email_id: string;` `assigned_project_code: number;` `assigned_project_name?: string;` `working_hrs_per_day: number;` `}` `const userData: IUser = {` `first_name: 'Jack',` `last_name: 'Panchal',` `email_id: 'yami@gmail.com',` `assigned_project_code: 1,` `working_hrs_per_day: 8` `};` **Note**: *Using* ***I*** *to start an interface name is a standard practice*. An optional property can be declared with a question mark (?). In the preceding example, `**assigned_project_name**` is marked as optional. `**userData**` is assigned a value to the interface in which `**assigned_project_name**` is not specified and it is not giving error but if any other property, for example, `**email_id**` is missed, TS would throw an error as follows: `Property 'email_id' is missing in type '{ first_name: string; last_name: string; assigned_project_code: number; working_hrs_per_day: number; }' but required in type 'IUser'.` We just saw a way to use interfaces to define an object directly. Another way is to create a class and implement the interface as follows: `class User implements IUser {` `first_name: string;` `last_name: string;` `email_id: string;` `assigned_project_code: number;` `assigned_project_name?: string;` `working_hrs_per_day: number;` `constructor(first_name: string, last_name: string) {` `this.first_name = first_name;` `this.last_name = last_name;` `}` `}` `const newUser = new User("Jack", "Panchal");` `newUser.email_id = 'yami@gmail.com';` ``console.log(`User = ${JSON.stringify(newUser)}`);`` `Output:` `User = {"first_name":"Jack","last_name":"Panchal","email_id":"yami@gmail.com"};` In the preceding example, we are creating a class User by implementing the `**IUser**` interface. After defining the class, we are creating a user variable to create an object of the class `**user**`. This can be rewritten as: `const user:IUser = new User("Jack", "Panchal");` Here, we are using interface `**IUser**` to let typescript know that the variable `**user**` is of type `**IUser**` and the `**user**` can contain only those properties which are declared by the interface `**IUser**`. # Abstraction Abstraction is a fundamental concept which allows us to define abstract classes and methods. The methods defined this way do not specify any implementation. Consider the following example where a `**BaseClass**` is defined as abstract using the keyword `**abstract**`. A `**setName()**` method is also defined as abstract. We can see that there is no implementation provided for this method. `**abstract** class Book {` `protected title: string;` `protected author: string;` `protected price: number;` `constructor(title: string, author: string, price: number) {` `this.title = title;` `this.author = author;` `this.price = price;` `}` `// Abstract method to display book information` `**abstract displayInfo()**: void;` `}` Implementation of the `**displayInfo**` method is the responsibility of the classes which will extend the `**Book**` class. Now, let us create another class which extends the `**Book**` class we just created: `// Concrete class representing a PrintedBook, extending Book` `class PrintedBook extends Book {` `private format: string;` `constructor(title: string, author: string, price: number, format: string) {` `super(title, author, price);` `this.format = format;` `}` `// Implementation of abstract method to display book information` `displayInfo(): void {` ``console.log(`Title: ${this.title}`);`` ``console.log(`Author: ${this.author}`);`` ``console.log(`Price: $${this.price}`);`` ``console.log(`Format: ${this.format}`);`` `}` `}` `// Create instances of books` `const printedBook = new PrintedBook('The Great Gatsby', 'F. Scott Fitzgerald', 15.99, 'Hardcover');` `// Display book information` `printedBook.displayInfo();` Output: `Title: The Great Gatsby` `Author: F. Scott Fitzgerald` `Price: $15.99` `Format: Hardcover` The example of the class `**PrintedBook**` did its own implementation of the `**displayInfo()**` method. Any other class which extends `**Book**` class can choose their own implementation for the method. # Encapsulation Encapsulation is hiding internal data and making it available only through some other way such as creating a getter function. In simple terms, it restricts visibility of all actual details of code and displays only the outer layer of code through access modifiers. **Example:** `class BankAccountClass {` `private accountNumber: string;` `private balance: number;` `constructor(accountNumber: string, initialBalance: number) {` `this.accountNumber = accountNumber;` `this.balance = initialBalance;` `}` `deposit(amount: number): void {` `this.balance += amount;` ``console.log(`Deposited ${amount} into account ${this.accountNumber}. New balance: ${this.balance}`);`` `}` `withdraw(amount: number): void {` `if (amount > this.balance) {` `console.log("Insufficient funds.");` `} else {` `this.balance -= amount;` ``console.log(`Withdrawn ${amount} from account ${this.accountNumber}. New balance: ${this.balance}`);`` `}` `}` `getAccountInfo(): void {` ``console.log(`Account Number: ${this.accountNumber}, Balance: ${this.balance}`);`` `}` `}` `// Create an instance of the BankAccount class` `const myAccount = new BankAccountClass('123456789', 1000);` `// Accessing properties and methods using encapsulation` `myAccount.deposit(500); // Deposited 500 into account 123456789\. New balance: 1500` `myAccount.withdraw(200); // Withdrawn 200 from account 123456789\. New balance: 1300` `myAccount.getAccountInfo(); // Account Number: 123456789, Balance: 1300` `// Attempting to access private members directly (will result in TypeScript compilation error)` `// console.log(myAccount.accountNumber); // Error: Property 'accountNumber' is private and only accessible within class 'BankAccount'.` `// console.log(myAccount.balance); // Error: Property 'balance' is private and only accessible within class 'BankAccount'.` Output: `Original customer details:` `Name: John Doe, Email: john@example.com, Phone Number: 123-456-7890` `Updated customer details:` `Name: Jane Smith, Email: jane@example.com, Phone Number: 987-654-3210` In the example, the `**accountNumber**` and balance properties are private, and they can only be accessed or modified within the `**BankAccount**` class itself. Encapsulation ensures that the internal state of the `**BankAccount**` object is protected, and external code cannot directly manipulate it. Instead, interactions with the object’s state are performed through well-defined methods like deposit, withdraw, and `**getAccountInfo**`, which encapsulate the underlying data and behavior. In addition, `**accountNumber**` and balance are private members of class so that is not accessible outside of the class. If anyone tries to set value directly, then it gives an error of not being accessible; so sometimes it provides advantage of security using private members. `// Attempting to access private members directly (will result in TypeScript compilation error)` `console.log(myAccount.accountNumber); // Error: Property 'accountNumber' is private and only accessible within class 'BankAccount'.` `console.log(myAccount.balance); // Error: Property 'balance' is private and only accessible within class 'BankAccount'.` # Polymorphism In *Polymorphism*, *Poly* means many and *morphism* means form. It is a concept that refers to the ability of objects to take on multiple forms depending on the context. We can use polymorphism to create classes which implement the same interface or base class. Let us consider a simple example: `// Define an interface for a printable item` `interface IPrintable {` `print(): void;` `}` Now let’s create two classes implementing the interface: `// Implement the Printable interface for a Book class` `class Books implements IPrintable {` `constructor(private title: string, private author: string) { }` `// Implement the print method from the Printable interface` `print(): void {` ``console.log(`Title: ${this.title}`);`` ``console.log(`Author: ${this.author}`);`` `}` `}` `// Implement the IPrintable interface for a Document class` `class Documents implements IPrintable {` `constructor(private name: string) { }` `// Implement the print method from the Printable interface` `print(): void {` ``console.log(`Document Name: ${this.name}`);`` `console.log("This is a Printable document.");` `}` `}` `// Create instances of Book and Document` `const book = new Books("The Great Gatsby", "F. Scott Fitzgerald");` `const doc = new Documents("Sample Document");` `// Call the printItem function with different Printable items` `book.print();` `doc.print();` Output: `Title: The Great Gatsby` `Author: F. Scott Fitzgerald` `Document Name: Sample Document` `This is a printable document.` In the preceding example, both the `**Books**` and `**Documents**` classes implement the `**IPrintable**` interface, which defines a `**print()**` method. By implementing the same method with different behavior in each class, we create objects of different types that can be used interchangeably wherever an `**IPrintable**` is expected. The `**print()**` method in each class is specific to the type of `**book**`, and the actual behavior is determined at runtime based on the specific object being used. This is an example of runtime polymorphism, where the behavior of the method is determined at runtime based on the actual type of the object, rather than at compile-time. So, when we call `**book.print()**`, the `**print()**` method in the Books class is executed, and when we call `**Document.print()**`, the `**print()**` method in the Documents class is executed. This demonstrates the ability of objects to take on multiple forms depending on their specific implementation, which is the essence of polymorphism. # ECMAScript Features ECMAScript is a standard of JavaScript that was first introduced in 2015; then onwards every year in the month of June, a new version is released. Currently, ES2022 is the latest version for ECMAScript. It is the 13th edition. The name ECMAScript comes from the fact that it was developed by Ecma International, a non-profit organization that was formerly known as the European Computer Manufacturers Association (ECMA). Let us quickly jump into the features of ECMAScript. # Arrow Functions Arrow Function is an anonymous function with short syntax compared to vanilla JavaScript. It does not have self-binding so cannot be used as a method or constructor. In normal JavaScript function, `"**this**"` keyword refers to the function where it is called, whereas in arrow function, `"**this**"` keyword refers to a global object or its parent object. It is also not a binding of arguments. Syntax of arrow function is as following: `let arrowFunction = (arg1, arg2, …argN) => {` `statement(s)` `}` Let us take an example of a simple function that just adds two numbers. The normal JavaScript function would be: `function sum (num1, num2) {` `return num1 + num2;` `}` The same function in form of arrow functions can be written as: `((num1, num2) => {` `return num1 + num2;` `})` # Using this Keyword Consider the following example: `let projectName = "PMS";` `let project = {` `projectName: "Project Management System",` `normalFunction() {` `console.log(this.projectName, this);` `},` `arrowFunction: () => {` `console.log(this.projectName, this);` `}` `};` In the preceding example, we have two functions available for the variable project. See that we have `**projectName**` as variable on global scope and `**projectName**` as a property inside the project object. If we make a call to normal function, the output would be: `project.normalFunction();` `// output in browser` `Project Management System, {projectName: 'Project Management System', normalFunction: ƒ, arrowFunction: ƒ}` The `**'this'**` keyword here refers to the object project and prints the project object. Now let us call the arrow function: `project.arrowFunction();` `// Output in browser` `undefined, Window {window: Window, self: Window, document: document, name: '', location: Location, …}` Here, in the arrow function, the binding does not happen and the `**'this'**` keyword refers to the Window object of the browser. In the window object, there is no `**projectName**` variable, hence we got undefined as the first value in the log. The behavior would be different for the arrow function if we run the same function in the terminal. Since there is no window object in the terminal, it outputs a blank object. `//In Terminal` `undefined, {}` # Using the new Keyword In JavaScript, the new keyword is used to create a new instance of an object. In the case of arrow function, this behavior is different. If we try to use a new keyword to create an object of an arrow function, we would get a ``**TypeError**``. The arrow functions are designed to be used as expressions rather than constructors. `// arrow function with new keyword` `const Task = () => {};` `const obj = new Task(); // TypeError: Task is not a constructor` Output: VM37:2 Uncaught TypeError: Task is not a constructor at <anonymous>:2:13 (anonymous) @ VM37:2 # Blocking Scopes 在 JavaScript 中,可以使用 "**var**" 关键字声明变量,它可以被重新声明和重新赋值,具有函数作用域,而在 ES6 中,引入了 **let****const**,它们具有不同的作用域用法。 # The let Keyword 使用 let 关键字声明的变量具有块级作用域,并且只能在声明它们的块中访问。这些变量可以被重新赋值,但不能被重新声明。 让我们考虑以下示例: let name = "Project"; let nameIsDeclared = true; if(nameIsDeclared) { let name = "PMS"; console.log(name); } console.log(name); // Output PMS Project 如前例所示,name 变量被声明并赋值为 "**Project**",它具有全局作用域。之后,相同的 name 变量在块函数中被声明并赋值为 PMS。在这种情况下,它被重新声明,但由于作用域从全局变为块,因此不会出错。 然而,如果在同一全局作用域中重新声明,则会报错,错误信息为 **caught SyntaxError: Identifier 'name' has already been declared** # The const Keyword **const** 关键字也有块级作用域,与 **let** 相同,但它不能被重新赋值为新值。它的值保持不变,正如其名称所暗示的那样。 在以下示例中,我们尝试重新赋值给 **const** **name**,这是不允许的,因此我们得到编译时错误: const name = "PMS"; name = "Project Management system"; Output: VM1717:2 Uncaught TypeError: Assignment to constant variable. at <anonymous>:2:6 在变量持有对象的情况下,行为略有不同。我们知道 Object 是一种引用类型。如果 const 用于持有对象,则可以更改其属性,但不能用另一个对象替换整个对象。 让我们考虑以下示例: const Task = { name: "Insert Data to database", efforts_hrs: 10 }; Task.name = " Fetch Data from database"; console.log(Task.name); Output: Fetch Data from database 在这种情况下,Task 的 name 属性可以被更改。 # Template Literals ECMAScript 支持模板字符串,其语法由反引号(**` `**)包围的字符串。在字符串内部,我们可以使用美元 (**$**) 符号来放置变量或表达式,这将进行评估并打印其值。 let name = "PMS"; let task = "Insert data to database"; console.log("Task => " + task + " and name => " + name); // without template console.log(`Task => ${task} and name => ${name}`);  // with template 这避免了使用加号(+)来连接多个字符串或变量。 # Classes ES6 允许使用面向对象的概念,类就是其中之一。一个类由对象、方法和构造函数组成。类可以用类关键字声明。 语法: class class_name { } 示例: class Project { constructor() { } } 我们在本章前面已经看到了类的详细信息。 # Promises 在引入 **ES6** 之前,回调用于执行或处理异步任务。**Callback** 是一种在实际函数执行完成后被调用的函数类型。当存在多个嵌套回调时,会创建一个称为回调地狱的情况。为了解决这个问题,引入了 **Promises**。 Promise 的语法如下: new Promise((resolve,reject) => {….}); Promise 有以下三种状态: * **pending**: 它是初始状态,在 reject 或 resolve 状态之前。 * **reject**: 它是在发生失败时所处的状态。 * **resolve**: 它是在执行成功完成时所处的状态。 让我们考虑以下示例: const example = new Promise((resolve, reject) => { try { const options = { body: JSON.stringify({test:"test"}), headers: { 'Content-Type': 'application/json' }, method: 'POST', }; const url = `https://www.google.com`; fetch(url, options) .then(async (response) => { console.log(`Got response - ${await response.text()}`); resolve(response); }) .catch((err) => { console.log('Catch => ', err); reject(err); }).finally(() => { console.log("Finally"); }); } catch (error) { console.log('Catch 2 => ', error); reject(error); } }); 在前例中,当 fetch 的结果返回时,根据结果的好坏,我们可以调用 **resolve()****reject()**。正如我们所看到的,在成功的结果中,我们调用 **resolve()**,在捕获到错误的情况下,我们调用 **reject()**。 使用 Promise 的语法现在也已过时。现在,用于处理异步请求的是 **async await**。可以使用 **async await** 语法重写相同的函数,如下所示: async function example() { // line 1 try { const options = { body: JSON.stringify({test: "test"}), headers: {'Content-Type': 'application/json'}, method: 'POST' }; const url = 'https://www.google.com'; const response = await fetch(url, options); console.log(`Got response - ${await response.text()}`); return response; } catch (error) { console.log('Catch => ', error); throw error; } finally { console.log('Finally'); } } 要使函数使用 async-await,我们需要将函数声明为 async,就像我们在第 1 行所做的那样。现在,我们可以使用 await 对我们想要等待的调用进行操作。在前例中,我们在 **fetch()** 之前使用了 **await**。执行将等待 fetch 完成,并返回一些结果。 # Destructuring 解构允许我们将对象的属性和数组的值转换为相同的变量。 # Object Destructuring 让我们考虑以下示例来了解它是如何工作的: const obj = { a: 1, b: 2 }; const { a, b } = obj; console.log(a); console.log(b); Output: 1 2 在这个示例中,我们可以直接从对象的属性声明两个变量 **a****b**。 # Array Destructuring 让我们考虑以下示例来了解它是如何工作的: const numbers = ["1", "2", "3"]; const [red, yellow, green] = numbers; console.log(red); // "1" console.log(yellow); // "2" console.log(green); // "3" Output: 1 2 3 在数组的情况下,值可以按照在数组中出现的顺序分配给变量。 # Default Parameters 在 ES6 中,可以通过函数的参数为任何变量设置默认值。 设置默认值的语法如下: function fnName(param1 = defaultValue1, /* …,*/ paramN = defaultValueN) { // … } 让我们定义一个函数来添加两个值: function sum(num1, num2=5) { let sum = num1 + num2; return sum; } 前面的函数可以这样调用: sum(1, 3)  // 返回 4 sum(3)  // 返回 8 函数可以不指定默认参数的值来调用。 # Modules ES6 提供了导出和导入功能。在这种情况下,任何编写的 JavaScript 代码都可以作为模块从一个文件转移到另一个文件,以便作为导出从文件导出到另一个文件进行重用。 让我们创建一个名为 project.js 的文件,如下所示: export class Project { //objects //constructor //methods } 使用 export 关键字导出 Project 类。让我们创建另一个名为 main.js 的文件,在其中我们可以导入 Project 类: import {Person} from './project.js' 现在,所有属性和函数都可以在 main.js 文件中使用。这样,在 ES6 引入之后,JavaScript 中就可以使用 export-import。 # Enhanced Object Literals 在 ES6 中,可以有效地为对象创建动态属性。它还使初始化对象具有简写语法。 让我们考虑一个名为 **getTask()** 的函数的示例,它是在常规 JavaScript 中: // regular JavaScript function getTask(name, hrs) { return { name: name, hrs: hrs } } getTask("Task 1", 10); Output : {name: 'Task 1', hrs: 10} 我们可以将相同的函数重写为 **ES6**,如下所示: //ES6 function getTask(name, hrs) { return { name, hrs }; } getTask("Task 1", 10); Output: {name: 'Task 1', hrs: 10} 现在,让我们添加一些动态属性: let name = "t"; let i = 0; const task = { [name + ++i]: "Add", [name + ++i]: "Update", [name + ++i]: "Delete" }; 我们向 task 对象添加了三个更多属性作为 **t1****t2****t3**console.log(task.t1); console.log(task.t2); console.log(task.t3); Output: Add Update Delete 这些是 **ES6** 中最常用的功能。还有一些其他功能可以通过参考 ECMAScript 的官方网站来探索和使用:262.ecma-international.org。 # EsLint EsLint 是最好的工具之一,它根据定义的规则检查 JavaScript 和 ECMAScript 代码,并突出显示不符合规则的内容。所有主要的 IDE 都支持 **eslint**,如果违反了该规则,开发人员会立即得到通知。这有助于节省大量的开发工作。 # Installing Eslint 要在项目中安装 **Eslint**,只需安装以下包: npm install eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev # Configuring Eslint 让我们在项目的根目录中创建一个名为 **.eslintrc** 的文件。现在将以下代码保存到 **.eslintrc** 文件中,其中包含定义的规则和解析器选项: { "env": { "browser": true, "es2021": true, "node": true }, "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 12, "sourceType": "module" }, "plugins": [ "@typescript-eslint" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended" ], "rules": { "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-unused-vars": "error", "no-console": "warn", "indent": ["error", 2], "quotes": ["error", "single"], "semi": ["error", "always"] } } # Running Eslint 虽然所有 IDE 都会立即根据提供的 **.eslintrc** 文件评估代码,但我们也可以在终端中手动运行 **eslint**。 打开项目的 **package.json** 文件并保存以下 lint 脚本: "scripts": { **…** "**lint**"**:** "**eslint . --ext .ts**" } 现在,我们可以运行 **lint** 脚本: $ npm run lint Figure 2.5: 运行 Eslint 一些 **Eslint** 问题可以自动修复,我们可以通过在脚本中使用 **-- fix** 选项来修复这些问题: "scripts": { **…** "**lint**"**:** "**eslint . --ext .ts --fix**" } 这在存在小错误时很有用,例如缺少分号、多余的空白等。 如上所示,一些修复需要手动进行。为了演示目的,我们没有删除控制台语句,这些语句需要手动删除或添加 eslint 规则。 这就是 **Eslint** 在项目中用于更好开发的情况概述;如果您想了解更多关于它的信息,请参考 EsLint 文档 (eslint.org/docs/latest/) 之前,有一个名为 **tslint** 的 TypeScript linting 工具,现在已经弃用。计划是使用 **Eslint** 为 TypeScript 项目提供服务。路线图可以在 github.com/palantir/tslint/issues/4534 跟踪。 # Conclusion 在本章中,我们通过示例学习了基本的 TypeScript,它的安装和配置。我们学习了 OOP 概念以及 ECMAScript 提供的内容。 在下一章中,我们将学习 Express.js,这是 Node.js 中最常用的框架。Express.js 将帮助我们构建本书后续章节中需要的 API。 # Multiple Choice Questions 1. 什么是 TypeScript? 1. A JavaScript 库用于创建用户界面 2. A statically typed superset of JavaScript 3. A database management system 4. A programming language for server-side development 2. 使用 TypeScript 的以下哪个优点? 1. 它在浏览器中运行而不需要编译 2. 它强制执行严格的类型规则以提前捕获错误 3. 它提供内置的数据库连接支持 4. 它可以用于移动应用开发 3. TypeScript 代码是如何编译成 JavaScript 的? 1. 它由浏览器解释 2. 它由

第三章

Express.js 概述

介绍

Express.js 是 Node.js 中高度认可和频繁使用的开源框架,它促进了网络应用程序和 REST API 的创建。它是一个强大且适应性强的框架,受到寻求构建高效和可扩展网络应用程序的开发者的青睐。

由于其简单性、灵活性和可扩展性,它是一个非常受欢迎的框架。其受欢迎程度可以通过查看每周的平均下载量来观察。根据 npm 注册表,Express.js 包每周平均下载量超过 2700 万次。

结构

在本章中,我们将讨论以下主题:

  • 定义 Express.js

  • Express.js 的优势和局限性

  • Express.js 安装和创建基本应用程序

  • Express.js 的核心功能

  • 安全性和性能最佳实践

定义 Express.js

在软件开发中,一个 框架 是一段预写的代码,它提供了一套通用功能、工具和指南,用于构建应用程序。它本质上是一种结构化和标准化的软件开发方式,通过提供可跨多个项目重用的预存在组件和模式来帮助减少开发时间和努力。

Express.js 是 Node.js 中最受欢迎的开源、快速和灵活的框架之一。它遵循“无偏见”的方法,这意味着它不强制执行任何特定的架构或模式,并允许开发者使用他们偏好的工具和技术来构建他们的应用程序。它提供了一套用于使用 Node.js 构建网络应用程序和 API 的工具和功能,包括处理 HTTP 请求和响应、路由、中间件、模板引擎、静态文件服务等等。它拥有一个庞大且活跃的开发者社区,他们为它的开发和维护做出贡献。

Express.js 以其性能和可扩展性而闻名,拥有轻量级和高效的内核,使其能够处理高流量负载。总的来说,Express.js 是一个功能强大的 Node.js 框架,拥有开发安全且可扩展网络应用程序所需的所有功能。

Express.js 的优势

与其他网络应用程序框架相比,Express.js 有几个优势,包括:

  • 简约

    Express.js 具有直观的设计,有助于降低新接触框架的开发者的学习曲线,使他们能够更快、更轻松地开始他们的项目。

  • 灵活

    Express.js 是一个高度可定制和灵活的框架。其灵活性的一个例子是其中间件系统,它允许开发者向传入的请求或传出的响应添加自定义逻辑。

  • 可扩展

    Express.js 提供了使用 JavaScript 承诺和 **async**/**await** 语法进行异步编程的内置支持,这使得开发者能够编写可扩展的代码,能够处理大量的并发请求。它非常适合构建大型、复杂的应用程序,能够处理高流量和数据。

  • 与 Node.js 的兼容性

    Express.js 是专门为与 Node.js 一起工作而构建的,这意味着它与 Node.js 及其相关库高度兼容。Express.js 被设计为利用 Node.js 的特性和功能,例如其事件驱动架构和非阻塞 I/O 模型。它还能够无缝集成其他 Node.js 库和工具。

  • 协作社区

    它拥有一个非常庞大且活跃的社区,这是它的一个关键优势。这确保了框架不断发展和改进。此外,它还为开发者提供了宝贵的支持,使他们更容易解决问题并从他人的经验中学习。

Express.js 不仅提供了上述好处,还提供了许多其他优势。

Express.js 的局限性

就像每个硬币都有两面一样,Express.js 也有其局限性,如下所示:

  • 与客户端应用程序不兼容

    Express.js 主要关注服务器端 Web 开发,因此它不用于构建复杂的客户端应用程序。然而,它可以与其他工具和框架结合使用来构建全栈应用程序。

  • 缺乏内置功能

    Express.js 是一个极简框架,这意味着它不提供某些其他框架提供的所有内置功能和工具。开发者可能需要安装和配置额外的模块或库,以向他们的应用程序添加某些功能。

  • 不一致性

    由于 Express.js 是一个极简框架,它不强制执行任何标准的方式来结构化应用程序或组织其代码。因此,开发者可以自由地设计他们的结构,这可能会给试图为任何现有项目做出贡献的新开发者带来挑战。

与其他框架相比,Express.js 的局限性更少,并且提供了更多的好处,使其成为开发者易于接触的框架。

Express.js 安装和创建基本应用程序

在安装 Express.js 之前,请确保您的机器上已安装 Node.js,然后您可以执行以下步骤:

  1. 创建一个新的项目目录,并使用命令提示符或终端进入该目录。

    $ mkdir my-express-app

    $ cd my-express-app

    初始化一个新的 Node.js 项目。这将创建一个位于项目目录中的 **package**.**json** 文件。

    $ npm init

  2. 使用以下命令安装 **Express.js** 和 TypeScript 依赖项:

    $ npm install express typescript --save

    使用--save选项将自动更新您的**package.json**文件,包括已安装的包及其版本。

  3. 将 express.js 作为开发依赖项安装到 typescript

    $ npm install @types/express @types/node --save-dev

  4. 在您的项目的根目录中创建一个名为**app.ts**的新文件,并添加以下代码:

    import * as express from 'express';

    import { Request, Response } from 'express';

    const app: express.Application = express();

    app.get('/', (req: Request, res: Response) => {

    res.send('Hello World!');

    });

    app.listen(3000, () => {

    console.log('Server listening on port 3000');

    });

  5. 使用**tsc**命令将 TypeScript 代码编译成 JavaScript:

    $ tsc app.ts

  6. 使用**node**命令运行服务器:

    $ node app.js

    图 3.1

    图 3.1:编译并运行 Express 应用程序

现在您应该能够在您的网页浏览器中访问**http://localhost:3000**,并看到页面显示的消息为"**Hello World!**"

图 3.2

图 3.2:启动 Express 应用程序

Express.js 的核心功能

Express.js 配备了几个基本功能,其中一些如下:

REST API

RESTful API 是一种遵循表示状态转移(REST)架构风格的基于 Web 的 API。它是一种设计轻量级、可维护和可扩展的 Web 服务的方式。RESTful API 使用 HTTP 方法与由 URI 标识的资源进行交互。

在构建 REST API 时,应遵循某些原则。这些原则是一组设计应用程序的指南。

REST 原则

总共有六个指导原则。一般来说,构建应用程序时并不强制遵循所有这些原则;然而,使用这些原则可以确保更好的性能、效率和可扩展性。这些原则是:

  • 客户端-服务器架构

    系统应分为客户端和服务器。客户端应通过网络与服务器通信。服务器端和客户端的责任必须是独立的,并由各自的一方实现。这允许客户端和服务器独立进化。

  • 无状态设计

    当客户端需要从服务器获取任何数据时,它会向后端服务器发送一个请求。这个原则指出,客户端到服务器的每个请求都必须包含服务器理解请求所需的所有信息。服务器不得存储关于客户端的会话状态。

  • 可缓存

    有时,有些响应并不经常改变。这些响应可以被缓存以提高性能。响应应该定义为可缓存的,这样客户端也可以知道是否可以重用相同的数据或应该再次请求。

  • 统一接口

    统一接口通过使其更加模块化来简化架构,并允许更轻松的开发和部署。定义统一接口有四个约束:

    • 资源标识:资源可以通过请求 URI 进行标识。例如,/projects 清楚地表明我们正在请求项目列表。URI /projects/23 表示我们正在使用唯一 ID 23 请求一个项目。

    • 通过表示进行资源操作:资源是一个通过 URI 标识的概念性实体,表示是资源在网络中传输时的形式。表示可以是 JSON、XML、HTML 等。通过发送或接收这些表示,客户端操作资源。

      一个项目可以表示(以 JSON 格式)为

      {

      "name":"Mobile App",

      "description":"This project is to manage development of Mobile App"

      }

    • 自描述消息:在服务器和客户端之间传输的消息应包含足够的信息来描述如何处理该消息。这有助于解耦客户端和服务器。让我们考虑一个请求:

      GET /projects/23 HTTP/1.1

      Host: example.com

      Accept: application/json

      上述内容可以是客户端向服务器发出的请求。这清楚地表明我们正在通过 HTTP 向 example.com 主机发出 GET 请求,并希望使用 Accept 头以 JSON 格式接收响应。让我们看看这个请求的示例响应:

      HTTP/1.1 200 OK

      Content-Type: application/json

      Content-Length: 122

      {

      "id": 23,

      "name":"Mobile App",

      "description":"This project is to manage development of Mobile App"

      }

      上述响应显示请求的状态为**200 OK**,返回的数据是 JSON 格式,并且正文包含请求的项目。

    • 超媒体作为应用程序状态引擎(HATEOAS):该原则表示客户端应完全通过应用程序服务器动态提供的超媒体与 RESTful 应用程序交互。客户端将只有应用程序的初始 URI。进一步需要的超链接将包含在响应中。这允许动态发现操作,并有助于解耦客户端和服务器。这里的每次通信都会有自描述的消息。

  • 分层架构

    这个原则坚持认为应用程序架构应该分为分层层。每一层执行特定的任务。让我们考虑一个简单的 Web 应用程序。其中的层可以是:

    • 客户端层:用户与此层交互,例如 Web 应用程序或移动应用程序。

    • API 网关层:入口点,每个请求都通过这一层。

    • 应用层:处理处理用户请求的业务逻辑。

    • 服务层:包含辅助服务,如通知服务等。

    • 数据访问层:包含获取、存储或更新数据所需的逻辑。

    • 数据库层:与数据库通信以获取或存储数据的层。

    这些层应该能让人了解其原理。这是一个示例,不同的系统可能会有不同的层。

  • 按需代码

    这是一个可选原则。它允许客户端通过提供代码来扩展客户端功能。在这种情况下,客户端发起请求,服务器响应一个通常可以在客户端运行的脚本。这个原则允许灵活性和即时定制客户端应用程序。然而,我们必须小心,并应考虑安全性方面。

虽然 REST 原则确实增强了 API 的可扩展性、性能和可维护性,但并非所有原则在所有情况下都是强制性的。最后一个原则——按需代码是可选的。然而,为了实现 REST 架构的全部好处,建议尽可能遵循这些原则。

构建 REST API

Express.js 非常适合构建 RESTful API,它支持 HTTP。它允许开发者轻松处理 HTTP 请求和响应。HTTP 有不同类型的请求方法,如**GET****POST****PUT****DELETE****PATCH****HEAD**

让我们构建一个 REST API,使用 HTTP 的 GET 方法获取用户列表。

我们已经在上一章中创建了一个基本的 typescript 项目,所以让我们以那个项目为起点,并在其中安装**express**

$ npm install express --save

在 REST API 中,**body-parser**是一个非常有用的 npm 包,用作 Node.js 解析中间件。它提取传入请求的正文部分,并根据**Content-Type**请求头进行解析。解析后的正文数据随后通过**req.body**属性提供。让我们使用 npm 包管理器安装它:

$ npm install body-parser --save

此外,让我们安装 Express 和**body-parser**的类型定义作为开发依赖项:

$ npm install -D @types/express @types/body-parser

现在让我们更新项目根目录下的 **main.ts** 文件,添加以下代码:

import express from 'express';

import * as bodyParser from 'body-parser';

import { users } from "./users/user";

import { Application } from 'express';

const app: Application = express();

app.use(bodyParser.json());

app.get('/api/users', (req, res) => {

res.json(users);

});

app.listen(3000, () => {

console.log('Server listening on port 3000');

});

接下来,在**lib**目录下的**user**目录中创建一个**user.ts**文件,并放入以下代码:

interface User {

id: number;

name: string;

email: string;

}

export const users: User[] = [

{ id: 1, name: 'John', email: 'john@example.com' },

{ id: 2, name: 'Jane', email: 'jane@example.com' },

];

要运行服务器,我们需要使用 **tsc** 编译 TypeScript 代码,并使用 node 启动服务器:

$ tsc

$ node dist/main.js

现在,我们可以使用 Postman/curl 等工具测试 API,或者直接通过 URL http://localhost:3000/api/users 打开浏览器,它将返回以下 JSON 输出:

[{"id":1,"name":"John","email":"john@example.com"},{"id":2,"name":"Jane","email":"jane@example.com"}]

图 3.3:获取 API 用户

在前面的示例中,我们创建了一个返回用户列表的 API 端点 /api/users。我们还使用了 **body-parser** 中间件。我们将在本章后面学习中间件。

路由

Express.js 提供了一个简单且灵活的路由系统,允许开发者为处理传入的 HTTP 请求定义 URL 路由。Express.js 中的路由是指定义和处理 Web 应用程序和 API 的端点(URL 路径)的机制。

这是任何 Web 框架的关键方面,因为它有助于确定应用程序如何响应用户请求。在 Express.js 中,路由是通过使用 **express.Router()** 类来完成的,该类创建模块化、可挂载的路由处理程序。路由器由路由方法、路由路径和回调处理程序组成。

app.METHOD(PATH, HANDLER)

**app** : 它是 express 的一个实例。

**METHOD** : 它是一个小写的 HTTP 请求方法。

**PATH** : 它是服务器上的一个路径。

**HANDLER** : 它是在路由匹配时执行的功能。

路由方法

最常用的方法如下:

**GET** : 它用于检索数据。

**POST** : 它用于创建或添加新数据。

**PUT** : 它用于更新现有数据。

**DELETE** : 它用于删除数据。

**PATCH** : 它用于部分更新现有数据。

**OPTIONS** : 它用于检索有关数据可用选项的信息。

**HEAD** : 它与 GET 方法类似,但仅检索响应头,而不检索响应体。

您还可以使用 **app.all()** 来处理所有 HTTP 方法。

app.all('/', (req, res, next) => {

console.log('all method…')

next() // 将控制权传递给下一个处理程序

})

路由路径

路径可以是字符串、字符串模式或正则表达式。

此路由路径将匹配对 **/users** 特定字符串的请求。

app.get('/users, (req, res) => {

res.send('users')

})

此路由路径将与字符串模式(如 **abcd****abbcd****abbbcd** 等)匹配。

app.get('/ab+cd', (req, res) => {

res.send('ab+cd')

})

此路由路径将匹配蓝莓和草莓、树莓等,但不匹配 **blueberriesfruit****strawberriesfruit** 等。

app.get(/.*berries$/, (req, res) => {

res.send('/.*berries$/')

})

这些是定义路由路径的不同方式。

路由参数

Express.js 在基于字符串的路径中对待某些字符的方式与它们的正则表达式对应物不同。

例如,?、+、* 和 () 都是它们正则表达式对应物的子集。另一方面,当用于基于字符串的路径时,连字符 (-) 和点 (.) 被字面地解释。

考虑以下示例:

app.get('/projects/:projectCode', function(req, res) {

var projectCode = req.params.projectCode;

// 对项目代码进行操作

res.send('项目代码: ' + projectCode);

});

app.get('/users/:user-email', function(req, res) {

var userEmail = req.params['user-email'];

// 对用户邮箱进行操作

res.send('用户邮箱: ' + userEmail);

});

app.get('/files/:file_name.pdf', function(req, res) {

var fileName = req.params['file_name'];

// 对文件名进行操作

res.send('文件名: ' + fileName);

});

在这个例子中,有三个参数包含连字符和点的路由。第一个路由 **/projects/:projectCode** 接受一个项目代码参数,它可以包含连字符。第二个路由 **/users/:user-email** 接受一个用户邮箱参数,它可以包含连字符。第三个路由 **/files/:file_name.pdf** 接受一个文件名参数,它可以包含连字符并以 .pdf 结尾。

路由处理器

当在 Express.js 中匹配路由时,它可以有一个或多个与之关联的处理函数,这些函数将被执行。路由处理器负责处理请求、访问数据并向客户端返回响应。

让我们来看以下示例:

app.get('/api/users', (req, res) => {

res.json(users);

});

在这个例子中,路由处理器函数是 **(req, res) => {…}**,它在接收到与模式 **/api/users** 匹配的 URL 路径的 **GET** 请求时执行。**req** 参数包含有关传入请求的信息,例如请求头和参数,而 **res** 参数用于向客户端发送响应。

中间件

Express.js 支持中间件函数,可以根据所需的自定义逻辑修改传入的请求或传出的响应。Express.js 中的中间件是指在客户端向服务器发送请求时按特定顺序执行的一系列函数。这些函数可以访问请求和响应对象,并根据需要修改它们。中间件函数可用于各种目的,如日志记录、身份验证、错误处理等。

在 Express.js 中,可以使用 **use()** 方法将中间件函数添加到应用程序或特定路由。

在构建 REST API 的过程中,我们之前已经使用了 body-parser 作为中间件。这个中间件是针对每个路由,通过 **app.use()** 传入的,例如。

app.use(bodyParser.json());

另一个中间件示例,你不想在每个路由上使用它,例如在特定路由上的用户验证。它们按照添加的顺序执行,并且可以使用 **next()** 函数链接在一起,以将控制权传递给堆栈中的下一个中间件函数。

// 验证中间件

const validate = (req, res, next) => {

const { name } = req.body;

if (!name) {

return res.status(400).send('Name is required');

}

next();

};

// 路由

app.post('/users', validate, (req, res) => {

const { name } = req.body;

res.send(`Hello, ${name}!`);

});

在这个例子中,验证中间件函数被定义为检查名称参数是否存在于请求体字符串中。如果不存在,中间件会发送一个带有错误消息的 400 错误请求响应。如果名称参数存在,中间件会调用 **next()** 函数将控制权传递给下一个中间件或路由处理程序。

验证中间件随后在 **/user** 路由处理程序中作为第二个参数使用,以确保在生成用户响应之前名称参数存在。

图 3.4: Post API 用户无效请求

图 3.5 显示了结果 "**Hello, Yamini!**"

图 3.5: Post API 用户有效输入名称

错误处理

错误处理是构建健壮应用程序的重要方面。该框架提供了一些处理错误的方法:

内置错误处理

Express.js 提供了一个内置的错误处理中间件函数,可用于处理应用程序中的错误。此中间件函数可用于捕获在应用程序执行期间发生的任何未处理的错误。

在 Express.js 中,另一种处理错误的方法是利用具有错误优先回调或函数的中间件函数。以下是一个演示如何实现的示例:

import express, { Request, Response, NextFunction } from 'express';

const app = express();

app.get('/', (req: Request, res: Response) => {

throw new Error('Oops! Something went wrong.');

});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {

res.status(500).send('Something went wrong!');

});

app.listen(3000, () => {

console.log('Server listening on port 3000!');

});

在这个例子中,我们定义了一个具有单个路由的 Express 应用程序,该路由会抛出错误。然后我们使用内置的错误处理中间件函数 **app.use** 来捕获错误并向客户端发送带有消息的 **500** 状态码。

错误处理中间件函数接受四个参数:err、req、res 和 next。第一个参数(**err**)是抛出的错误,第二个参数(**req**)是请求对象,第三个参数(**res**)是响应对象,第四个参数(**next**)是一个用于将控制权传递给堆栈中下一个中间件函数的函数。

如果在任何一个中间件或路由处理函数中抛出错误,Express.js 将自动调用错误处理中间件函数,并将抛出的错误对象作为第一个参数。

图 3.6:错误处理

自定义错误处理

开发者也可以创建他们自己的自定义错误处理中间件来处理特定类型的错误。此中间件函数可以添加到中间件堆栈中,并用于捕获特定于应用程序的错误。

让我们更新 **main.ts** 文件,添加以下代码:

import express, { Application, Request, Response, NextFunction } from 'express';

import { HttpException, NotFoundException } from './utils/errorHandler';

import * as bodyParser from 'body-parser';

import { users, Users } from "./users/user";

const app: Application = express();

app.use(bodyParser.json());

app.get('/api/users', (req, res) => {

res.json(users);

});

app.get('/users/:id', (req, res, next) => {

const userId = req.params.id;

const user = new Users();

const isUserExist = user.getUserById(userId);

if (!isUserExist) {

return next(new NotFoundException(`User with ID ${userId} not found`));

}

res.status(200).json(user);

});

app.use((err: HttpException, req: Request, res: Response, next: NextFunction) => {

const status = err.status || 500;

const message = err.message || 'Internal server error';

res.status(status).json({ error: message });

});

app.listen(3000, () => {

console.log('Server listening on port 3000!');

});

utils 文件夹中创建 **errorHandler.ts** 文件,并将以下代码粘贴进去:

export class HttpException extends Error {

status: number;

message: string;

constructor(status: number, message: string) {

super(message);

this.status = status;

this.message = message;

}

}

export class NotFoundException extends HttpException {

constructor(message: string = 'Not Found') {

super(404, message);

}

}

现在更新 **user.ts** 文件,位于 users 目录中,添加以下代码:

interface User {

id: number;

name: string;

email: string;

}

export const users: User[] = [

{ id: 1, name: 'John', email: 'john@example.com' },

{ id: 2, name: 'Jane', email: 'jane@example.com' },

];

export class Users {

public getUserById(userId) {

if (users.find(i => i.id == userId)) {

return true;

} else {

return false;

}

}

}

在这个例子中,有一个自定义的 **HttpException** 类,它扩展了 Error 类并添加了一个状态属性。还有一个 **NotFoundException** 类,它扩展了 **HttpException** 类并将状态设置为默认的 404。

**/users/:id** 路由处理程序中,如果请求的用户未找到,则会抛出一个 **NotFoundException** 并传递给下一个函数,这会触发自定义错误处理中间件。

图 3.7: 用户未找到异常

自定义错误处理中间件检查错误是否是 **HttpException** 的实例,并使用状态和消息属性发送带有适当 HTTP 状态码的 JSON 响应。如果错误不是 **HttpException** 的实例,它将发送一个通用的 500 错误响应。

图 3.8: VsCode 编辑器中的示例代码

异步错误处理

在 Express.js 中,可以使用 try-catch 块或通过返回一个拒绝的 Promise 来处理异步错误。

**main.ts** 文件中添加以下代码:

// 异步函数抛出错误

async function asyncFunction(): Promise<void> {

throw new Error('Async error');

}

// 调用异步函数的异步路由处理程序

app.get('/async-error', async (req: Request, res: Response, next: NextFunction) => {

try {

await asyncFunction();

res.send('Success');

} catch (error) {

next(error);

}

});

// 错误处理中间件

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {

console.error(err.message);

res.status(500).send('Something broke!');

});

现在用 **$ tsc** 编译并运行代码,然后使用 **$ node dist/main.js**。之后,用浏览器打开 **http://localhost:3000/async-error**,它会显示为 Something broke!

在这个例子中,我们有一个名为 **asyncFunction** 的异步函数,它会抛出一个错误。我们有一个路由处理程序调用此函数,并使用 try-catch 块捕获发生的任何错误。如果发生错误,则使用带有错误参数的下一个函数调用,将错误传递给错误处理中间件。

错误处理中间件函数接受四个参数:errreqresnext。如果在调用此函数之前的任何中间件或路由处理程序中发生错误,它将被传递给此中间件函数。中间件函数将错误记录到控制台并向客户端发送一个 **500 内部服务器错误** 响应。

注意,在路由处理程序函数之前使用 **async** 关键字来指示它是一个异步函数。此外,在调用 **asyncFunction** 之前使用 **await** 关键字来等待函数完成,然后再继续执行下一行代码。

图 3.9: 异步错误 API

图 3.10 展示了终端输出:

图 3.10: 异步错误终端输出

在 Express.js 应用程序中正确处理错误非常重要,以确保应用程序健壮且可靠。

静态文件服务

在 Express.js 中,我们可以使用**express.static()**中间件函数提供静态文件,例如图像、CSS、JavaScript 文件等。

app.use(express.static('public'));

在前面的示例中,我们正在从公共目录提供静态文件。**express.static()**中间件函数接受一个参数,即包含静态文件的目录名称。

一旦设置好中间件,你可以通过指定相对于公共目录的 URL 来访问你的静态文件。例如,如果你在**public/images**目录中有一个名为**profilePic.png**的文件,你可以在**http://localhost:3000/images/profilePic.png**访问它。

图 3.11: 静态服务图像文件

模板引擎

在 Express.js 中,模板引擎用于生成 HTML 标记和动态渲染视图。模板引擎允许你创建带有占位符的模板,这些占位符可以在模板渲染时用实际数据替换。

Express.js 支持的流行模板引擎包括:

  • EJS(嵌入式 JavaScript)

  • Pug(以前称为 Jade)

  • Handlebars

  • Mustache

要在 Express.js 应用程序中使用模板引擎,你需要使用 npm 安装该引擎,并将其设置为应用程序配置中的默认视图引擎。然后你可以使用所选模板引擎的语法和功能创建视图。

打开终端,使用以下命令安装**ejs**的依赖项:

$ npm install ejs --save

一旦安装了**ejs**,我们可以尝试以下示例代码:

app.set('view engine', 'ejs');

app.set('views', path.join(__dirname, 'views'));

app.get('/ejs', (req, res) => {

const data = {

title: 'My App',

message: 'Hello, I am from EJS !!'

};

res.render('index', data);

});

创建**index.ejs**文件并粘贴 html 代码。

<!DOCTYPE html>

<html>

<head>

<title><%= title %></title>

</head>

<body>

<h1><%= message %></h1>

</body>

</html>

在这个示例中,我们使用**<%= %>**语法输出在路由处理程序中传递给视图的"**title**""**message**"变量。当视图渲染时,这些变量将被它们相应的值替换。

TypeScript 编译器处理生成 JavaScript 文件并将它们传输到**dist**文件夹的任务。然而,它不处理复制其他必要项目文件,如 EJS 视图模板。为了解决这个问题,你可以创建一个负责将所有附加文件复制到**dist**文件夹的构建脚本。

在编译你的 TypeScript 代码后,要自动将文件从视图文件夹复制到**dist**文件夹,你可以使用像**copyfiles**或 copy 这样的构建工具。

首先,使用以下命令在终端中安装**copyfiles**包作为开发依赖项:

$npm install --save-dev copyfiles

现在更新**package.json**中的脚本如下:

"scripts": {

"build": "tsc && npm run copy-views",

"copy-views": "cpy 'views/*' dist/views/ --recursive"

}

然后使用以下代码在终端中执行脚本:

$ npm run build

图 3.12:构建应用程序

运行应用程序后,您可以在网络浏览器中打开并导航到**http://localhost:3000/ejs**。这将显示浏览器中的 HTML 输出,如下面的图像所示。

图 3.13:浏览器 EJS 模板

安全和性能最佳实践

在使用 Express.js 开发应用程序时,有一些安全最佳实践需要遵循:

  • 使用安全的 HTTP 协议:始终使用 HTTPS 而不是 HTTP,以确保客户端和服务器之间的安全通信。

  • 使用最新版本:保持 Express.js 版本更新,并在可用时立即应用安全补丁。

  • 避免使用已弃用或易受攻击的包:仅使用最新、维护良好的包,并避免使用已弃用或易受攻击的包。

  • 验证用户输入:始终验证用户输入以防止注入攻击、跨站脚本(XSS)攻击和其他恶意活动。

  • 使用内容安全策略(CSP):通过限制页面可以加载的资源来实施内容安全策略(CSP),以防止跨站脚本(XSS)攻击。

    示例

    app.use((req, res, next) => {

    res.setHeader(

    'Content-Security-Policy',

    "default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"`

    );

    next();

    });

    然后将 CSP 头设置在添加到 Express 应用的中间件函数中。在这个示例中,CSP 策略允许从同一域名加载脚本('unsafe-inline')。图像和字体也允许从同一域名以及 data:协议加载。

*** 实现速率限制:实现速率限制以防止暴力攻击和其他涉及重复请求的攻击类型。

**示例**:

首先,使用 npm 安装`**express-rate-limit**`包:

`$ npm install express-rate-limit`

然后,引入该包并使用所需选项创建一个新的速率限制器对象:

`const rateLimit = require("express-rate-limit");`

`const limiter = rateLimit({`

`windowMs: 15 * 60 * 1000, // 15 分钟`

`max: 100 // 限制每个 IP 每 windowMs 100 次请求`

`});`

在这个示例中,我们创建了一个速率限制器,限制每个 IP 地址在 15 分钟内每 100 次请求。

最后,将速率限制中间件应用到所需的路由:

`app.use(limiter);`

`app.get("/", (req, res) => {`

`res.send("Hello World!");`

`});`

现在,每个到达根路由(`"`/`"`) 的请求都将与速率限制器进行核对。如果 IP 地址在指定的时间窗口内超过了最大请求数量,中间件将返回一个 429 `"**Too Many Requests**"` 错误。

+   **使用 helmet**:使用 helmet 中间件向 HTTP 响应添加额外的安全头,例如 X-XSS-Protection、X-Content-Type-Options 和 X-Frame-Options 头。

**示例**:

`import helmet from "helmet";`

`// 使用 Helmet 中间件`

`app.use(helmet());`

`// 向应用程序添加路由`

`app.get("/", (req, res) => {`

`res.send("Hello, world!");`

`});`

在这个示例中,从 helmet 包中导入 helmet 中间件,并使用 `**app.use(helmet())**` 在应用程序中使用它。这将自动向 HTTP 响应添加安全头,例如将 X-Content-Type-Options 头设置为 `**nosniff**` 以防止浏览器将响应内容解释为不同的 MIME 类型。

注意,这只是一个基本示例,根据您应用程序的具体安全需求,可能还需要额外的配置。

+   **使用安全 cookies**:当使用 cookies 时,设置 secure 和 `**httpOnly**` 标志以防止跨站脚本(XSS)和跨站请求伪造(CSRF)攻击。

**示例**:

`import cookieParser from 'cookie-parser';`

`app.use(cookieParser('secret'));`

**使用代码检查器和安全扫描器**:使用代码检查器和安全扫描器来检测和修复代码中的潜在安全问题。最佳示例是 `**EsLint**`。**

`res.cookie('myCookie', 'someValue', {`

`httpOnly: true,`

`sameSite: 'strict',`

`secure: true`

`});`

`res.send('Cookie set successfully!');`

`});`

在这个示例中,cookie-parser 中间件用于解析传入请求中的 cookies。secret 参数用于签名和加密 cookies。

`/set-cookie` 路由使用 `**res.cookie()**` 方法设置一个新的 cookie。httpOnly 选项防止 JavaScript 代码访问 cookie,使得攻击者使用跨站脚本(XSS)攻击窃取 cookie 更加困难。sameSite 选项限制 cookie 的作用域为设置它的同一站点,降低跨站请求伪造(CSRF)攻击的风险。secure 选项确保 cookie 只通过 HTTPS 发送,从而保护它免受网络攻击者的拦截。

通过遵循安全 cookie 处理的最佳实践,您可以显著提高 Express.js 应用程序的安全性。

+   **实施安全的部署流程**:实施一个安全的部署流程,包括安全配置、代码审查和测试,以防止安全漏洞被引入生产环境。

遵循这些安全最佳实践,可以帮助确保您的 Express.js 应用程序安全,并防止常见的安全威胁

在使用 Express.js 开发应用程序时,有一些性能最佳实践需要遵循:

  • 避免使用同步函数:建议使用异步代码,因为生产环境中的同步代码会减慢应用程序的运行速度,因此尽量避免不必要的同步函数,并使用 **async**/**await** 与承诺一起使用。

  • 异常处理:始终使用 try catch 在代码级别处理异常,以防止在运行时破坏应用程序。

  • 减少中间件使用:仅使用所需的中间件,避免过度使用。中间件可能非常消耗资源,可能会减慢应用程序的运行速度。

  • 缓存:对频繁请求的数据(如静态文件或 API 响应)实现缓存。通过缓存这些数据,您可以减少服务器需要处理请求数量,从而显著提高应用程序的响应时间。

  • 使用集群:使用 Node.js 集群模式来利用所有可用的 CPU 核心,并将负载均匀地分配到所有核心,从而提高应用程序的性能。

  • 自动重启应用程序:确保如果应用程序在任何时候崩溃,则自动重启,因此请使用进程管理器或 PM2 或 Forever 等包来实现。

通过遵循这些推荐的最佳实践,您可以提升您的 Express.js 应用程序的性能,从而实现更快速、更高效的用户体验。

结论

在本章中,我们学习了 Express.js,这是一个流行的、强大的 Node.js Web 应用程序框架。我们了解了它的提供内容、优点和局限性。虽然它有一些局限性,但 Express.js 的好处,如活跃的社区、广泛的文档和对 REST API 开发的支撑,使其成为 Web 开发项目的绝佳选择。

在下一章中,我们将开始构建一个项目管理系统应用程序。我们将以此作为一个大练习来学习 TypeScript 和 Express.Js 的概念。

多项选择题

  1. Express.js 主要用于什么?

    1. 数据库管理

    2. 前端开发

    3. 构建 Web 应用程序和 API

    4. 机器学习

  2. 与 Express.js 框架相关联的优点和缺点是什么?

    1. 优点包括其轻量级和极简设计,而缺点涉及复杂应用程序缺少内置功能。

    2. 优点包括为复杂应用程序提供广泛内置功能,而缺点涉及性能不佳。

    3. 优点包括为高流量应用程序提供自动扩展,而缺点涉及学习曲线挑战。

    4. 优点包括与数据库的无缝集成,而缺点涉及缺少路由支持。

  3. Express.js 路由对象的作用是什么?

    1. 为多个应用程序定义路由

    2. 创建中间件函数

    3. 处理应用程序中的错误

    4. 为应用程序的特定部分定义路由

  4. 中间件在 Express.js 应用程序中扮演什么角色?

    1. 以托管静态文件

    2. 建立路由

    3. 管理传入的请求和响应

    4. 以便进行身份验证

  5. 你如何在 Express.js 中处理路由参数?

    1. 使用 req.routeParams 对象

    2. 通过为每个参数定义单独的路由处理程序

    3. 直接从 URL 访问它们

    4. 使用 req.params 对象

  6. Express.js 中间件中的 next() 函数做什么?

    1. 结束请求-响应周期

    2. 将控制权传递给下一个中间件函数

    3. 向客户端发送响应

    4. 将信息记录到控制台

  7. 你如何使用中间件在 Express.js 应用程序中处理错误?

    1. 使用 catchError 中间件函数

    2. 将代码包裹在 try-catch 块中

    3. 使用应用对象的错误事件

    4. 使用四个参数定义错误处理中间件

  8. 以下哪个 Express.js 中间件通常用于解析 JSON 请求?

    1. express-static

    2. body-parser

    3. cookie-parser

    4. express-session

  9. 在 Express.js 应用程序中,"cookie-parser" 中间件的主要目的是什么?

    1. 生成用于用户会话的随机 cookie。

    2. 解析和处理传入的 HTTP 请求。

    3. 解析附加到传入 HTTP 请求的 cookie。

    4. 设置安全的 HTTP 头部以处理 cookie。

  10. 在 Express.js 应用程序中,用于处理跨源资源共享 (CORS) 的中间件是哪个?

    1. express-cors

    2. cors-express

    3. cross-origin

    4. cors

答案

  1. c

  2. a

  3. d

  4. c

  5. d

  6. b

  7. d

  8. b

  9. c

  10. d

进一步阅读

expressjs.com

blog.dreamfactory.com/rest-apis-an-overview-of-basic-principles/

restfulapi.net/

第四章

规划应用程序

介绍

Node.js 已成为编写应用程序的首选选择。超过 3000 万个网站由 Node.js 驱动。这些应用程序或网站或项目在一般情况下都有相当复杂的部分。Netflix、LinkedIn、Uber、Paypal 甚至类似 NASA 的组织都使用 Node.js 来开发他们的应用程序。

开发应用程序时,规划是关键部分。它有助于明确目标并确定实现既定目标的路径。

在本书的剩余部分,我们将遵循一个共同示例来学习应用程序的重要方面。我们将看到如何使用标准技术编写、优化和测试应用程序。我们将编写一个项目管理软件。

结构

在本章中,我们将讨论以下主题:

  • 项目管理应用程序概述

  • 数据库设计

  • 设置项目结构

    • 安装项目依赖

    • 项目目录结构

  • 数据库模型(实体)

  • 路由

应用程序概述

任何项目管理软件都有一个简单的目标定义。让个人和团队规划、组织和执行任务,以有效地完成项目。项目管理软件应该允许用户创建和管理项目。对于每个项目,都可能有许多任务需要执行。用户应该能够相互沟通。我们将很快了解到这些任务以及更多内容。

我们的项目管理系统(PMS)项目的目标或范围是开发一个小型 Web 应用程序,作为基本的项目管理工具。该应用程序将包括各种功能,包括安全的用户登录、创建项目和管理任务的能力、基于用户角色的任务分配,以及当任务被分配或完成时实施电子邮件通知。

此外,项目还将探索实现 API 的缓存机制以提高请求响应时间。此外,它还包括为开发者提供单元测试信息,以及如何为生产环境构建应用程序,最后是部署。

为了熟悉我们将要构建的内容,我们需要定义目的、将要执行的任务、架构、项目结构、数据库设计以及更多内容。

路线图

一个典型的项目路线图涉及几个关键阶段和活动。以下是路线图涵盖的高级概述。

  • 规划应用程序

  • 定义模块

  • 数据库设计

  • 设置项目结构

  • 初始化项目

  • 连接数据库

  • API 开发

  • API 缓存

  • 单元测试

  • 部署

在本章中,我们将涵盖前面列表中的前六个主题。最后四个主题有它们各自的章节。

范围

如前所述的目的已经给出了一个很好的想法,即我们将开发一个项目管理系统。有时,我们也会将其称为 PMS。我们的第一个目标是确定我们的 PMS 应该具备哪些功能。

我们应该能够:

  • 管理用户并登录到应用。

  • 创建和管理项目。

  • 为创建的项目创建和管理任务。

  • 应该能够访问和更新任务详情。

  • 应该能够与任务上的其他成员进行沟通。

  • 在仪表板上提供一些基本的项目报告。

定义模块

根据我们应能执行的任务,我们可以看到 PMS 系统应该具备以下必备模块。

用户模块

此模块将使我们能够管理用户。我们应该能够执行以下操作:

  • 添加新用户。

  • 编辑用户。

  • 删除用户。

  • 用户登录。

  • 获取用户个人资料。

  • 重置用户密码。

项目模块

项目模块将使我们能够管理项目。以下关键任务应通过此模块执行:

  • 创建新项目。

  • 更新项目详情。

  • 删除项目。

  • 获取项目详情。

  • 获取项目统计信息。

  • 管理项目成员资格。

任务模块

此模块将使我们能够管理项目内的任务。这是一个关键模块,它将使我们能够:

  • 创建新任务。

  • 更新任务详情。

  • 删除任务。

  • 获取任务详情。

  • 附加支持文件。

评论模块

任务总是需要一个方式来使团队成员之间能够进行沟通。评论模块与任务一起将促进这一功能。以下是我们应该能够执行的任务:

  • 在任务上添加评论。

  • 允许用户更新自己的评论。

  • 允许用户和管理员删除评论。

数据库设计

为大型项目设计数据库是一个棘手、持续的活动,它随着项目时间表而即兴发挥。尽管它必须尽早定义。它涉及定义模式、表、关系、约束和数据类型,以满足应用的特定要求。

规范化通过应用规范化规则来消除数据冗余,有助于优化包括数据完整性在内的存储。规范化的目标是避免数据重复并保持数据完整性。每条数据只存储在一个地方,从而降低不一致的风险。表之间的关系通过主键和外键来维护。

规范化在存储、非冗余和整洁性方面有其好处。然而,随着数据库的增长,表的数量以及应用中添加的复杂功能,连接的数量和查询的复杂性也在增加。这影响了可读性,并在有大量读取操作时导致性能变慢。

在本节中,我们将设计我们实体所需的表。我们将尽量保持关系最小化,同时在可能的情况下在冗余和规范化之间保持平衡。

注意对于本书,我们将使用 PostgreSQL 数据库。因此以下表中的数据类型是特定于 PostgreSQL 的。对于每个表,我们还将指定列是否是主键或唯一键,以及它是否可以包含空值。如果我们打算引用另一个表中的列,那么我们将提及引用表和列。

对于上述定义的所有模块,我们需要以下表来构建我们的数据库:

  • **用户**: 代表可以访问系统的所有用户

  • **角色**: 每个用户都将被分配一个角色,例如,项目经理、管理员和其他。

  • **项目**: 代表项目实体。

  • **任务**: 与每个项目关联的任务。

  • **评论**: 添加到每个任务的评论。

图 4.1 展示了一个基本的实体关系图。此图仅使用每个表使用的唯一键和外键。

图 4.1: 高级实体关系图

图表显示每个用户都被分配了一个角色。每个项目属于一个用户。每个项目都有分配的任务,每个任务都可以有评论。这是一个高级的实体关系图。现在当我们理解了表之间的关系后,我们可以详细地探讨每个实体,然后是详细的实体关系图。

用户架构表

让我们从用户表开始,它是用户认证、授权和维护用户相关数据的关键。它在确保每个用户信息被准确捕捉和保障的同时,使 PMS 能够有效地识别和管理单个用户,发挥着至关重要的作用。

为了简化,我们只添加了用户 id、姓名、密码和一些相关的详细信息。

列名 类型 数据类型 描述 主键? 是否为空 唯一 引用
user_id uuid uuid 用户的唯一标识符 id true false true -
email email varchar(60) 用户电子邮件地址 false false true -
full_name string varchar(30) 用户全名 false true false -
username string varchar(30) 用户的唯一名称 false false true -
password string varchar(100) 用户加密密码 false false false -
role_id uuid uuid 用户的角色 id false false false 角色表 (role_id)
created_at date timestamp 用户创建时间 false false false -
updated_at date timestamp 用户数据更新时间 false true false -

表 4.1: 用户表

角色架构表

用户访问应用程序的权限由分配的角色和授予该角色的权限决定。用户可以根据其分配的角色所关联的权限在应用程序内执行特定的操作。我们已定义具有以下约束的应用程序角色模式表。

列名 类型 数据类型 描述 主键? 是否为空 唯一
role_id uuid uuid 角色的唯一标识符 id
name email varchar(60) 角色名称
description string varchar(30) 角色描述
rights string text 不同模块的权限
created_at date 时间戳 角色创建时间
updated_at date 时间戳 角色数据更新时间

表 4.2:角色表

项目模式表

在项目管理系统(PMS)中,项目表作为存储系统内管理不同项目信息的中心仓库。项目表中的每一行代表一个特定的项目,而每一列存储与这些项目相关的各种属性或细节。我们已定义如下模式:

列名 类型 数据类型 描述 主键? 是否为空 唯一 引用
project_id uuid integer 项目的唯一标识符 id -
name string varchar(60) 项目名称 -
description string varchar(200) 项目描述 -
user_ids string text 项目分配的用户 _ids 用户表(user_id)
start_time date 时间戳 项目开始日期 -
end_time date 时间戳 项目结束日期 -
status enum varchar(30) 当前项目状态 -
created_at date 时间戳 用户创建时间 -
updated_at date 时间戳 用户数据更新时间

表 4.3:项目表

任务模式表

任务表旨在存储与特定项目相关的各种任务或活动的信息。任务表中的每一行代表一个特定的任务,而每一列则持有与这些任务相关的不同属性或细节,如下所示:

列名 类型 数据类型 描述 主键? 是否为空 唯一 引用
task_id uuid uuid 任务的唯一标识符 id -
name string varchar(60) 任务名称 -
description string varchar(300) 任务描述 -
project_id uuid varchar(35) 关联的项目 ID false true false 项目表 (project_id)
user_id uuid uuid 分配的用户 ID false true false 用户表 (user_id)
estimated_start_time date 时间戳 任务的预计开始日期 false true false -
estimated_end_time date 时间戳 任务的预计结束日期 false true false -
actual_start_time date 时间戳 任务的实际开始日期 false true false -
actual_end_time date 时间戳 任务的实际结束日期 false true false -
priority enum varchar(20) 任务的优先级,例如,高、低、中 false true false -
status enum varchar(30) 当前任务状态 false false false -
supported_files string 文本 支持的文件 URL false true false -
created_at date 时间戳 任务的创建时间 false false false -
updated_at date 时间戳 任务的更新时间 false true false -

表 4.4: 任务表

评论架构表

评论表的设计是为了存储与各种任务相关的评论或笔记。评论表中的每一行代表与特定任务相关的特定评论,而每一列则持有与这些评论相关的不同属性或细节,如下所示。

列名 类型 数据类型 描述 主键? 是否为空 唯一 引用
comment_id uuid uuid 评论的唯一标识符 ID true false true -
comment string varchar(300) 评论 false false false -
task_id uuid uuid 给定评论的任务 ID false false false 任务表 (task_id)
user_id uuid uuid 给定评论的用户 ID false false false 用户表 (user_id)
supported_files string 文本 附加文件的 URL false true false -
created_at date 时间戳 评论的创建时间 false false false -
updated_at date 时间戳 评论的更新时间 false true false -

表 4.5: 评论表

这是 PMS 数据库设计的概述,包括原始的架构表。现在我们已经准备好了架构,我们可以开始编写我们的项目了。我们首先将定义项目文件结构。

图 4.2: 实体关系图

设置项目结构

项目结构灵活,可以根据您的 Node.js 应用程序的具体需求进行定制,我们旨在通过在整个开发过程中遵循既定标准来最小化开发者的工作量。

让我们通过初始化项目开始开发 PMS 应用程序。

初始化项目

在开始之前,请确保你已经安装了系统上的 LTS(长期支持)版本的 Node.js 和 PostgreSQL。

PostgreSQL 可以从www.postgresql.org/download/下载。有适用于 Linux、MacOs、Windows、BSD 和 Solaris 的安装程序。由于 Linux 有多种版本,上述官方网站还提供了每种版本的单独说明。

项目目录名可以保持为 **pms-be**. **pms** 代表项目管理系统,而 **-be** 可以添加,因为我们正在创建应用程序的 API 后端。让我们创建一个新的目录 **pms-be**`` 并使用命令提示符 (cmd) 切换到它。在目录内,通过运行命令 "npm init."` 初始化一个新的 Node.js 项目。

一旦执行 **"npm init"**,设置过程将逐步提示你提供各种详细信息。你可以添加适当的信息,并按每个提示按 **"Enter"**

  • 包名: (默认为 **"**pms-be**"**)

  • 版本: (默认为 **"**1.0.0**"**)

  • 描述: **"**项目管理系统**"**

  • 入口点: (默认为 **"**index.js**"**) 如果需要,你可以将其更改为 **"**main.js**"**。

  • 测试命令: 如果你没有特定的测试命令,你可以留空此字段。

  • Git 仓库: 如果你的项目不使用 Git,请留空此字段。

  • 关键词: 添加相关的关键词,用逗号分隔。

  • 作者: 添加项目的作者姓名。

  • 许可证: (默认为 **"**ISC**"**) 你可以选择不同的许可证或保持为 **"**ISC.**"**

  • 再按一次 **"**Enter**"** 完成初始化过程。

通过这样,你的项目初始化过程将成功完成,并将创建 **package.json** 文件。

图片 4.3

图 4.3: 初始化项目

项目依赖安装

一旦项目初始化完成,下一步就是安装必要的包和工具作为依赖项。我们强烈推荐使用 Visual Studio Code 作为开发工具,因为它提供了各种扩展,例如编程语言的 intellisense、代码格式化工具和 git 包,这些扩展大大简化了开发过程。这些扩展帮助开发者节省大量精力,使开发体验更加高效和愉快。

现在,让我们开始安装项目管理系统开发所需的最基本包,例如 **express.js****postgresql(pg)****typescript****typeorm****ts-node****ts-lint** 等,在开发过程中,我们还将根据需要安装其他包。

npm install express  --save

npm install typescript -D

npm install pg --save

npm install typeorm --save

npm install @types/express -D

npm install @types/node -D

npm install @types/pg -D

npm install reflect-metadata --save

npm install uuid --save

npm install @types/uuid -D

npm install ts-node prettier eslint eslint-config-prettier eslint-plugin

-prettier -D

npm install @typescript-eslint/eslint-plugin @typescript-eslint/parser -D

在这里,我们分享**package.json**文件以供参考,用于安装所有包,并且它将随着开发过程而不断变化。

// package.json

{

"name": "pms-be",

"version": "1.0.0",

"description": "项目管理系统",

"main": "main.js",

"scripts": {

"test": "echo \"Error: no test specified\" && exit 1",

"start": "nodemon dist/src/main.js"

},

"author": "Ravi Kumar Gupta, Yamini Panchal",

"license": "Proprietary",

"devDependencies": {

"@types/express": "4.17.17",

"@types/node": "20.4.2",

"@types/pg": "8.10.2",

"@types/uuid": "9.0.2",

"@typescript-eslint/eslint-plugin": "5.59.8",

"@typescript-eslint/parser": "5.59.8",

"eslint": "8.41.0",

"eslint-config-prettier": "8.8.0",

"eslint-plugin-prettier": "4.2.1",

"nodemon": "2.0.22",

"prettier": "2.8.8",

"ts-node": "10.9.1",

"typescript": "5.0.4"

},

"dependencies": {

"express": "4.18.2",

"pg": "8.11.1",

"reflect-metadata": "0.1.13",

"typeorm": "0.3.17",

"uuid": "9.0.0"

}

}

接下来,在项目主目录中创建一个**tsconfig.json**文件,并使用以下代码以确保 TypeScript 能够编译代码:

// tsconfig.json

js```````js```````js `{` `"compilerOptions": {` `"module": "commonjs",` `"moduleResolution": "node",` `"pretty": true,` `"sourceMap": false,` `"target": "ESNext",` `"outDir": "./dist",` `"baseUrl": "./src",` `"noImplicitAny": false,` `"esModuleInterop": true,` `"removeComments": true,` `"preserveConstEnums": true,` `"experimentalDecorators": true,` `"alwaysStrict": true,` `"forceConsistentCasingInFileNames": true,` `"emitDecoratorMetadata": true,` `"resolveJsonModule": true,` `"skipLibCheck": true` `},` `"include": ["src/**/*.ts", "src/**/*.json"],` `"exclude": ["node_modules"],` `"files": ["node_modules/@types/node/index.d.ts"]` `}` Thereafter, create `**server_config.json**` which will be used for all configurations, such as, `**port**`, `**database config**`, and so on. `// server_config.json` ``````js```````js { "port": 3000, "db_config": { "db": "postgres", "username": "root", "password": "123456", "host": "localhost", "port": 5432, "dbname": "pms" } } Note: Please always keep stronger passwords for any account. As part of the configuration process, we will integrate **ESLint** to enhance development practices. We will include a **.eslintrc.json** file in the project’s root directory, as demonstrated in the code snippet as follows: .eslintrc.json { "env": { "browser": true, "es2021": true }, "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "overrides": [], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" }, "plugins": ["@typescript-eslint"], "rules": { "linebreak-style": ["error", "unix"], "quotes": ["error", "single"], "semi": ["error", "always"] } } Nodemon is beneficial for Node.js developers as it enhances development workflow, improves productivity, and provides real-time feedback during the development process. With Nodemon, developers receive immediate feedback on code changes as the server restarts instantly. This allows for quick validation of code modifications and helps catch errors early in the development process. This eliminates the need to manually stop and restart the server after every code modification, saving time and streamlining the development process. Let us install **nodemon** open root directory of project on terminal and paste following command: $ npm install nodemon -D # Project Directory Structure When structuring a **Node.js** project with **Express** and **TypeScript**, a common directory structure can be as follows: pms-be/ |-- src/ |   |-- components/ |   |   |-- users/ |   |   |   |-- users_controller.ts |   |   |   |-- users_entity.ts |   |   |   |-- users_routes.ts |   |   |   |-- users_service.ts |   |   |-- projects/ |   |   |   |-- projects_controller.ts |   |   |   |-- projects_entity.ts |   |   |   |-- projects_routes.ts |   |   |   |-- projects_service.ts |   |   |-- tasks/ |   |   |   |-- tasks_controller.ts |   |   |   |-- tasks_entity.ts |   |   |   |-- tasks_routes.ts |   |   |   |-- tasks_service.ts |   |-- utils/ |   |   |-- db_utils.ts |   |   |-- common.ts |   |-- routes/ |   |   |-- index.ts |   |--express_server.ts |   |-- main.ts |-- tests/ |   |-- users_spec.tjs |-- package-lock.json |-- package.json |-- node_modules |-- tsconfig.json |-- server_config.json |-- .gitignore |-- README.md As shown in the project directory structure, we have already set up **server_config.json** and **tsconfig.json** files. As we proceed with the development, we will create various folders and files to organize the code and manage different aspects of the project. # Create Express Server Before creating an express server, add one **README.md** file that includes how this project will run and the required configuration so anyone can easily work on that. README.md # Project Management System API ## Prerequisites -   Node.js: Ensure that Node.js is installed on your machine. You can download it from [here](https://nodejs.org). ## Install Project Dependency Packages ```js npm install js` js` `## Configuration` `create server_config.json file in the root directory and save it. Inside that file, add the desired configuration values in the format KEY:VALUE.` `-   Configuration Options` `| Configuration Key | Description                           |` `| ----------------- | ------------------------------------ |` `| PORT              | The port number on which project run |` `## Compile Project` `Once you are in the project's root directory, you can use the tsc command with the --watch flag to enable automatic recompilation when changes are detected. Run the following command` ```` js `tsc --watch` ```js` ``` ```js` `## Run Project` `-   on local machine run during development` js ```` `nodemon dist/src/main.js` js ``` ```js -   on server after development ```js node src/main.js js` js` Let us create a new directory called `**"src"**` and inside it, create two files named `**"main.ts"**` and `**"express_server.ts"**` to set up an Express `HTTP` server with the following code. As our project is currently small-scale, we will not be using `**cluster**` and `**forks**` at the OS level, but we will implement reconnection to the server in case of any exceptions. Depending on your project’s specific requirements, you can choose to use the `**"main.ts"**` file with or without `**cluster**` functionality. `// main.ts` ``````js```````js`` `import cluster from 'cluster';` `import { ExpressServer } from './express_server';` ``````js```````js` `// connect the express server` `const server = new ExpressServer();` `process.on('uncaughtException', (error: Error) => {` ``console.error(`Uncaught exception in worker process ${process.pid}:`, error);`` `// Close any open connections or resources` `server.closeServer();` `setTimeout(() => {` `cluster.fork();` `cluster.worker?.disconnect();` `}, 1000);` `});` `// Gracefully handle termination signals` `process.on('SIGINT', () => {` `console.log('Received SIGINT signal');` `// Close any open connections or resources` `server.closeServer();` `});` `process.on('SIGTERM', () => {` `console.log('Received SIGTERM signal');` `// Close any open connections or resources` `server.closeServer();` `});` # With Cluster for a Large Project With the presence of the `cluster` module, if the server crashes due to any reason, we can catch the event when the process exits and spawn another child. This way, we ensure that the server does not crash and it remains available for serving upcoming requests. `// main.ts` ``````js```````js `导入`cluster`模块从`'cluster';` `导入`os`模块从`'os';` `const numCPUs = os.cpus().length;` `导入`{ ExpressServer }`从`'./express_server';` `if (cluster.isPrimary) {` ``console.log(`主进程 PID: ${process.pid}`);`` `for (let i = 0; i < numCPUs; i++) {` `cluster.fork();` `}` `cluster.on('exit', (worker, code, signal) => {` ``console.log(`工作进程 ${worker.process.pid} 退出,代码 ${code} 和信号 ${signal}`);`` `setTimeout(() => {` `cluster.fork();` `}, 1000);` `});` `} else {` `// 连接 Express 服务器` `const server = new ExpressServer();` ``````js`````` `process.on('uncaughtException', (error: Error) => {` ``console.error(`工作进程 ${process.pid} 中未捕获的异常:` `` ``error);`` `// 关闭任何打开的连接或资源` `server.closeServer();` `setTimeout(() => {` `cluster.fork();` `cluster.worker?.disconnect();` `}, 1000);` `});` `// 优雅地处理终止信号` `process.on('SIGINT', () => {` `console.log('收到 SIGINT 信号');` `// 关闭任何打开的连接或资源` `server.closeServer();` `});` `process.on('SIGTERM', () => {` `console.log('收到 SIGTERM 信号');` `// 关闭任何打开的连接或资源` `server.closeServer();` `});` `}` 根据前面的代码,我们需要创建`**express_server.ts**`,因为它被导入。所以让我们创建它。 `// express_server.ts` jsjs` `import express, { Application } from 'express';` `import { IServerConfig } from './utils/config';` `import * as config from '../server_config.json';` `export class ExpressServer {` `private static server = null;` `public server_config: IServerConfig = config;` `constructor() {` `const port = this.server_config.port ?? 3000;` `// initialize express app` `const app = express();` ``````js```` `app.get('/ping', (req, res) => {` `res.send('pong');` `});` `ExpressServer.server = app.listen(port, () => {` ``console.log(`Server is running on port ${port} with pid = ${process.pid}`);`` `});` `}` `//close the express server for safe on uncaughtException` `public closeServer(): void {` `ExpressServer.server.close(() => {` `console.log('Server closed');` `process.exit(0);` `});` `}` `}` Afterwards, create a directory with `**utils**` and inside add `**config.ts**` file for define config interface as follows: `// config.ts` ```js`````` `export interface IServerConfig {` `port: number;` `db_config: {` `'db': string;` `'username': string;` `'password': string;` `'host': string;` `'port': number;` `'dbname': string;` `};` `}` 现在要运行应用程序,首先使用`**tsc --watch**`编译代码。之后,将创建一个`**dist**`文件夹,然后使用`**nodemon**` `**dist/src/main.js**`在开发应用程序时运行,将显示以下输出。 输出 : ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/4.4.jpg) **图 4.4:运行应用程序的输出** **注意**:*如果您正在使用 Visual Studio Code,可以轻松配置和运行 TypeScript 观察任务。要这样做,请按* `**"*Ctrl + P*"**`*,然后选择* `**"*Tasks: Run Task*"**` *并配置一个带有* `***TypeScript***`*的任务。选择* `**"*tsc:watch -tsconfig.json*"**` *以启用基于您的* `***tsconfig.json***` *文件的 TypeScript 编译的观察模式。* # 连接数据库 TypeORM 因其易用性、灵活性和数据库操作的抽象而广泛用于现代 TypeScript 和 JavaScript 应用程序。它简化了与 Node.js 中的数据库一起工作的开发过程,并提高了可维护性。我们已使用以下命令分别安装了 `**postgreSQL**` 和 `**typeorm**` 包:`**npm i pg --save**`**,** `**npm i @types/pg -D**` 和 `**npm i typeorm -- save**`。 现在在 utils 目录中创建 `**db.ts**` 文件,代码如下: `// db.ts` `import { DataSource } from 'typeorm';` `import { IServerConfig } from './config';` `import * as config from '../../server_config.json';` `export class DatabaseUtil {` `public server_config: IServerConfig = config;` `constructor() {` `this.connectDatabase();` `}` `private connectDatabase() {` `try {` `const db_config = this.server_config.db_config;` `const AppDataSource = new DataSource({` `type: 'postgres',` `host: db_config.host,` `port: db_config.port,` `username: db_config.username,` `password: db_config.password,` `database: db_config.dbname,` `entities: [],` `synchronize: true,` `logging: false,` `});` `AppDataSource.initialize()` `.then(() => {` `console.log('连接到数据库');` `})` `.catch((error) => console.log(error));` `} catch (error) {` `console.error('连接到数据库错误:', error);` `}` `}` `}` 目前,由于我们没有定义任何实体,所以我们有空的实体数组。然后在 `**main.ts**` 中导入数据库连接,代码如下: `// main.ts` `import cluster from 'cluster';` `import { ExpressServer } from './express_server';` `import { DatabaseUtil } from './utils/db';` ```js````` `// 连接 Express 服务器` `const server = new ExpressServer();` `// 连接数据库` `new DatabaseUtil();` `process.on('uncaughtException', (error: Error) => {` ``console.error(`工作进程 ${process.pid} 中未捕获的异常:` `` ``error);`` `// 关闭任何打开的连接或资源` `server.closeServer();` `setTimeout(() => {` `cluster.fork();` `cluster.worker?.disconnect();` `}, 1000);` `});` `// 优雅地处理终止信号` `process.on('SIGINT', () => {` `console.log('收到 SIGINT 信号');` `// 关闭任何打开的连接或资源` `server.closeServer();` `});` `process.on('SIGTERM', () => {` `console.log('收到 SIGTERM 信号');` `// 关闭任何打开的连接或资源` `server.closeServer();` `});` 运行后,将显示以下成功连接数据库的输出。 输出: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/4.5.jpg) **图 4.5:数据库连接输出** 这样,根据在 `**server_config.json**` 中定义的数据库配置,使用 typeorm 和 PostgreSQL 数据库连接。 # 数据库模型(实体) 我们已经看到了数据库模式表。要将数据库模式集成到代码中,我们将创建一个 components 目录和 roles、users、projects、tasks 和 comments 的子目录。在这些目录中的每一个,我们将创建相应的实体文件。 `components` `├── roles` `│   └── roles_entity.ts` `├── users` `│   └── users_entity.ts` `├── projects` `│   └── projects_entity.ts` `├── tasks` `│   └── tasks_entity.ts` `└── comments` `└── comments_entity.ts` 我们将首先定义 Roles 实体: # 角色实体 在 role 目录中创建 `**role_entity.ts**` 文件,代码如下。 `// role_entity.ts` ```js```` `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,` `UpdateDateColumn } from 'typeorm';` `@Entity()` `export class Roles {` `@PrimaryGeneratedColumn('uuid')` `role_id: string;` `@Column({ length: 60, nullable: false, unique: true })` `name: string;` `@Column({ length: 200 })` `description: string;` `@Column({ type: 'text' })` `rights: string;` `@CreateDateColumn()` `created_at: Date;` `@UpdateDateColumn()` `updated_at: Date;` `}` 代码从 TypeORM 导入必要的装饰器。这些装饰器用于定义实体及其属性。 `**@Entity()**` 装饰器将类 `Roles` 标记为 TypeORM 实体,表示它代表一个数据库表。它具有以 `**PrimaryGeneratedColumn**` 装饰的 `**role_id**` 作为主键,并自动生成 `UUID`,`name` 字段被定义为 `unique` 和 `not nullable`。 `**description**` 字段的最大长度为 200 个字符。`**rights**` 字段使用 `**text**` 数据类型来存储更大的文本数据。`**@CreateDateColumn()**` 和 `**@UpdateDateColumn()**` 装饰器处理相应插入和更新时间的自动插入数据。 # 用户实体 Users 实体表示应用程序数据库中的用户信息。使用以下代码创建 `users_entity.ts`: `// user_entity.ts` ```js``` `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,` `UpdateDateColumn, OneToOne, JoinColumn } from 'typeorm';` `import { Roles } from '../roles/roles_entity';` `@Entity()` `export class Users {` `@PrimaryGeneratedColumn('uuid')` `user_id: string;` `@Column({ length: 50, nullable: true })` `fullname: string;` `@Column({ length: 30, nullable: false, unique: true })` `username: string;` `@Column({ length: 60, nullable: false, unique: true })` `email: string;` `@Column({ nullable: false })` `password: string;` `@Column({ nullable: false })` `@ManyToOne(() => Roles)` `@JoinColumn({ name: 'role_id' })` `role_id: Roles['role_id'];` `@CreateDateColumn()` `created_at: Date;` `@UpdateDateColumn()` `updated_at: Date;` `}` 根据用户定义的实体,`**user_id**` 作为主键,具有自动生成的 `**uuid**`,`**username**` 和 `**email**` 将是唯一的,并且具有非空约束,`**fullname**` 允许最大 50 个字符,`**password**` 也具有非空约束。 在这种情况下,每个用户都与一个角色相关联,在它们之间建立多对一关系,因此用户和角色两个表之间建立了多对一连接。 `**@ManyToOne(() => Roles)**`: 这个装饰器指定了实体之间的多对一关系。它表示当前实体(可能代表用户或其他实体)与 Roles 实体之间有一个多对一关系。通过一个返回目标实体类的函数指定 Roles 实体。 `**@JoinColumn({ name: 'role_id**' **})**`: 这个装饰器指定了当前实体中作为外键以建立关系的列。在这种情况下,`**role_id**` 列被用作外键。 `**role_id: Roles['role_id**'**]**`: 这一行定义了一个 TypeScript 属性 `**role_id**`,其类型为 `**Roles['role_id**'**]**`。它很可能 Roles 是一个表示应用程序中角色的实体类,而 `**role_id**` 是该类中代表角色主键或唯一标识符的属性。 这是对多对一关系的基本概述。 # 项目实体 现在创建一个 `**projects_entity.ts**` 文件,代码如下: `// projects_entity.ts` ````` `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,` `UpdateDateColumn } from 'typeorm';` `@Entity()` `export class Projects {` `@PrimaryGeneratedColumn('uuid')` `project_id: string;` `@Column({ length: 30, nullable: false, unique: true })` `name: string;` `@Column({ length: 500 })` `description: string;` `@Column('uuid', { array: true, default: [] })` `user_ids: string[];` `@Column()` `start_time: Date;` `@Column()` `end_time: Date;` `@CreateDateColumn()` `created_at: Date;` `@UpdateDateColumn()` `updated_at: Date;` `}` The `**@PrimaryGeneratedColumn('uuid**'**)**` decorator specifies that the `**project_id**` property is the primary key of the `**projects**` table and will be automatically generated as a `**UUID**` when a new project is inserted. The `**@Column({ length: 30, nullable: false, unique: true })**` decorator is applied to the `**name**` property, indicating that it is a string with a maximum length of 30 characters. It is also marked as `**nullable: false**` (required) and `**unique: true**` (must be unique among projects). The `**@Column({ length: 500 })**` decorator is used for the description property, representing a string with a maximum length of 500 characters. The `**@Column('uuid**'**, { array: true, default: [] })**` decorator is applied to the `**user_ids**` property, indicating that it is an array of strings (`**string[]**`) that will store user IDs associated with the project. The default value for the array is set to an empty array (`**[]**`). The `@Column()` decorator is used for both the `start_time` and `end_time` properties, which represent Date objects. In the context of the application, every project comprises multiple tasks, and each task is linked to a single project. Consequently, within the tasks entity, there exists a one-to-one relationship with the project entity. Similarly user and role joins here project and task join will be established with join columns as `**project_id**` for both tables in the following task entity that we will explore in detail. # Task Entity Create a new file named `**tasks_entity.ts**` and include the following code in it: `// tasks_entity.ts` `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,` `UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';` `import { Users } from '../users/users_entity';` `import { Projects } from '../projects/projects_entity';` `export enum Status {` `NotStarted = 'Not-Started',` `InProgress = 'In-Progress',` `Completed = 'Completed',` `}` `export enum Priority {` `Low = 'Low',` `Medium = 'Medium',` `High = 'High',` `}` `@Entity()` `export class Tasks {` `@PrimaryGeneratedColumn('uuid')` `task_id: string;` `@Column({ length: 30, nullable: false, unique: true })` `name: string;` `@Column({ length: 500 })` `description: string;` `@Column()` `@ManyToOne(() => Projects, (projectData) => projectData.project_id)` `@JoinColumn({ name: 'project_id' })` `project_id: string;` `@Column()` `@ManyToOne (() => Users, (userData) => userData.user_id)` `@JoinColumn({ name: 'user_id' })` `user_id: string;` `@Column()` `estimated_start_time: Date;` `@Column()` `estimated_end_time: Date;` `@Column()` `actual_start_time: Date;` `@Column()` `actual_end_time: Date;` `@Column({` `type: 'enum',` `enum: Priority, // Use the enum type here` `default: Priority.Low, // Set a default value as Low` `})` `priority: Priority;` `@Column({` `type: 'enum',` `enum: Status, // Use the enum type here` `default: Status.NotStarted, // Set a default value as Not-Started` `})` `status: Status;` `@Column('text', { array: true, default: [] })` `supported_files: string[];` `@CreateDateColumn()` `created_at: Date;` `@UpdateDateColumn()` `updated_at: Date;` `}` The code imports necessary decorators from TypeORM, including `Entity`, `**PrimaryGeneratedColumn**`, `**Column**`, `**CreateDateColumn**`, and `**UpdateDateColumn**`. It also imports the `**Users**` and `**Projects**` entities, as they will be used to establish one-to-one relationships with the `Tasks` entity. `**Priority**` and `**Status**` `**enums**` define possible values for the status and priority columns of the `**Tasks**` entity. By using `**enums**`, we ensure that only specific predefined values can be assigned to these columns. `**@ManyToOne**` decorators establish many-to-one relationships with the `**Projects**` and Users entities. The first argument of `**@ManyToOne**` is a function that returns the related `**entity**` class. This signifies that many tasks can be associated with one project or user. This second argument denotes the inverse side of the relationship, indicating the properties `**project_id**` and `**user_id**` in the `**Projects**` and `**Users**` entities, respectively, which reference the tasks entity. The `**@JoinColumn**` decorator specifies the name of the foreign key column in the `**tasks**` table that links to the related `**Projects**` and `**Users**` entities. # Comment Entity Let us define the comment entity in `**comments_entity.ts**` with the following code: `// comments_entity.ts` `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,` `UpdateDateColumn, JoinColumn, OneToOne } from 'typeorm';` `import { Users } from '../users/users_entity';` `import { Tasks } from '../tasks/tasks_entity';` `@Entity()` `export class Comments {` `@PrimaryGeneratedColumn('uuid')` `comment_id: string;` `@Column({ type: 'text' })` `comment: string;` `@OneToOne(() => Users, (userData) => userData.user_id)` `@JoinColumn({ name: 'user_id' })` `user_id: string;` `@OneToOne(() => Tasks, (taskData) => taskData.task_id)` `@JoinColumn({ name: 'task_id' })` `task_id: string;` `@Column('text', { array: true, default: [] })` `supported_files: string[];` `@CreateDateColumn()` `created_at: Date;` `@UpdateDateColumn()` `updated_at: Date;` `}` The provided code includes essential TypeORM decorators like `Entity`, `**PrimaryGeneratedColumn**`, `**Column**`, `**CreateDateColumn**`, and `**UpdateDateColumn**`. Additionally, it imports the `Users` and `Tasks` entities, which play a crucial role in setting up one-to-one relationships with the `**Comments**` entity. The `**@PrimaryGeneratedColumn('uuid**'**)**` decorator specifies that the `**comment_id**` property is the primary key of the comments table and will be automatically generated as a `UUID` when a new comment is inserted. The comment property is of type text, allowing it to store larger textual data. `**@OneToOne**` decorators establish one-to-one relationships with the `Users` and `Tasks` entities. The first argument of `**@OneToOne**` is a function that returns the related `**entity**` class. Meanwhile, the second argument defines the inverse side of the relationship, referring to the properties `**user_id**` and `**task_id**` in the `Users` and `Tasks` entities, respectively. The `**@JoinColumn**` decorator then specifies the name of the foreign key column in the `**comments**` table, which references the related `Users` and `Tasks` entities. The `**supported_files**` property is an array of strings (`**string[]**`) which will store the urls of different files in the array. After defining all the entities, their purpose remains incomplete without synchronization to the database. So, let us synchronize them with the database and automatically create the corresponding tables. To achieve this, we can update the `**db.ts**` file with the following code: `// db.ts` `import { DataSource } from 'typeorm';` `import { IServerConfig } from './config';` `import * as config from '../../server_config.json';` `import { Roles } from '../components/roles/roles_entity';` `import { Users } from '../components/users/users_entity';` `import { Projects } from '../components/projects/projects_entity';` `import { Tasks } from '../components/tasks/tasks_entity';` `import { Comments } from '../components/comments/comments_entity';` `export class DatabaseUtil {` `public server_config: IServerConfig = config;` `constructor() {` `this.connectDatabase();` `}` `private connectDatabase() {` `try {` `const db_config = this.server_config.db_config;` `const AppDataSource = new DataSource({` `type: 'postgres',` `host: db_config.host,` `port: db_config.port,` `username: db_config.username,` `password: db_config.password,` `database: db_config.dbname,` `entities: [Roles, Users, Projects, Tasks, Comments],` `synchronize: true,` `logging: false,` `});` `AppDataSource.initialize()` `.then(() => {` `console.log('Connected to the database');` `})` `.catch((error) => console.log(error));` `} catch (error) {` `console.error('Error connecting to the database:', error);` `}` `}` `}` The entities array should include all the entity classes you have defined (`**Users**`, `**Projects**`, `**Tasks**`, and `**Comments**`) to be synchronized with the database. Setting `**synchronize:**` `**true**` ensures that the database tables are automatically created or updated to match the defined entities. Once you run this code, the defined entities will be synchronized with the database, and the corresponding tables will be created or updated accordingly. # Routes Routing plays a crucial role in modern application development. Routing provides a structured way to navigate through an application. By establishing routes, users can seamlessly interact with distinct sections of the application through designated URLs. Routing is basically a way to respond to any request coming to a specific endpoint based on method and parameters. Route Definition follows this pattern: `app.METHOD(PATH, HANDLER)` `**"app"**` refers to an instance of `Express`. `**"METHOD"**` pertains to an `HTTP` request method, written in lowercase. `**"PATH"**` designates a specific server path. `**"HANDLER"**` represents the function executed upon matching the route. Routes consist of basically four parts: * **URL Paths**: Each route definition includes a URL path or pattern that users can enter in the browser’s address bar or click as links. For example, `**"/api/login"**` * **HTTP Method**: Routes specify the HTTP methods (`**GET**`, `**POST**`, `**PUT**`, `**DELETE**`, and so on) that are allowed for each path. Different HTTP methods trigger different actions in the application. For example, a `GET` request might retrieve data, while a `POST` request might submit data. You can explore it in detail through this link [`developer.mozilla.org/en-US/docs/Web/HTTP/Methods`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) * **Route Handlers**: For each URL path, there is a corresponding handler function or controller that defines the actual logic for functionality to be executed. This can include rendering a specific view, fetching data from a database, processing user input, and more. * **Route Parameters**: Some paths might include dynamics that act as placeholders for specific values. These are often indicated with a colon, like `**":id"**`. For example, `**"/users/:id"**` could represent a user’s profile page, where `**":id"**` is replaced with the actual user’s ID. Let us construct routes following the outlined structure: `├── routes` `│   └── index.ts  ` `├── components` `├── roles` `│   └── roles_entity.ts` `│   └── roles_routes.ts` `│   └── roles_controller.ts` `├── users` `│   └── users_entity.ts` `│   └── users_routes.ts` `│   └── users_controller.ts` `├── projects` `│   └── projects_entity.ts` `│   └── projects_routes.ts` `│   └── projects_controller.ts` `├── tasks` `│   └── tasks_entity.ts` `│   └── tasks_routes.ts` `│   └── tasks_controller.ts` `|── comments` `|    └── comments_entity.ts` `|    └── comments_routes.ts` `|    └── comments_controller.ts` By the preceding structure, we will establish a `**"routes"**` directory, containing an `**"index.ts"**` file, within the `**"src"**` directory. This `**"index.ts"**` file will function as the root for the individual routes of each module. We will also create all individual routes and controller files for each component and update them as we develop APIs step by step. Each module encompasses functionalities for adding, updating, retrieving, and deleting, along with obtaining specific details about a particular data. Each route can cover CRUD (`**Create**`, `**Read**`, `**Update**`, `**Delete**`) operations having a basepoint as `**"/api/componentname"**` where `**componentname**` can be `**roles**`, `**users**`, `**projects**`, `**task**` and `**comments**`: * `**Create**`: This route allows the addition of new data. It is typically associated with an HTTP `**POST**` request to a path ex. `**"/api/componentname"**`. * `**Update**`: The update route lets you modify existing data. It is triggered by an HTTP `**PUT**` request to a path like `**"/api/componentname/:id"**`, where `**":id"**` represents the specific entries identifier. * `**Retrieve All**`: This route enables the retrieval of a list of all roles. It is often invoked via an HTTP `**GET**` request to the `**"/api/componentname"**` path. * `**Retrieve Specific**`: This route is used to fetch details about a specific role. It is initiated by an HTTP `GET` request to a path like `**"/api/componentname/:id"**`. * `**Delete**`: The delete route allows the removal of roles. It is typically triggered by an HTTP `DELETE` request to a path like `**"/api/componentname/:id"**`. # Role Routes Now, update `**role_controller.ts**` file with empty skeleton functions. `// role_controller.ts` `export class RoleController {` `public addHandler() {` `// addHandler` `}` `public getAllHandler() {` `// getAllHandler` `}` `public getDetailsHandler() {` `// getDetailsHandler` `}` `public async updateHandler() {` `// updateHandler` `}` `public async deleteHandler() {` `// deleteHandler` `}` `}` Afterwards, modified `**role_routes.ts**` with the following code: `// role_routes.ts` `import { Express } from 'express';` `import { RoleController } from './roles_controller';` `export class RoleRoutes {` `private baseEndPoint = '/api/roles';` `constructor(app: Express) {` `const controller = new RoleController();` `app.route(this.baseEndPoint)` `.get(controller.getAllHandler)` `.post(controller.addHandler);` `app.route(this.baseEndPoint + '/:id')` `.get(controller.getDetailsHandler)` `.put(controller.updateHandler)` `.delete(controller.deleteHandler);` `}` `}` Similarly, we will update all controller files by changing class names, such as `**UserController**`, `**ProjectController**`, `**TaskController**`, and `**CommentController**`, and import them in respective route files with modifying basepoint. # User Routes Let us modify `**users_controller.ts**` and `**users_routes.ts**` files, respectively, as follows: `// users_controller.ts` `export class UserController {` `public addHandler() {` `// addHandler` `}` `public getAllHandler() {` `// getAllHandler` `}` `public getDetailsHandler() {` `// getDetailsHandler` `}` `public async updateHandler() {` `// updateHandler` `}` `public async deleteHandler() {` `// deleteHandler` `}` `}` `// users_routes.ts` `import { Express } from 'express';` `import { UserController } from './users_controller';` `export class UserRoutes {` `private baseEndPoint = '/api/users';` `constructor(app: Express) {` `const controller = new UserController();` `app.route(this.baseEndPoint)` `.get(controller.getAllHandler)` `.post(controller.addHandler);` `app.route(this.baseEndPoint + '/:id')` `.get(controller.getDetailsHandler)` `.put(controller.updateHandler)` `.delete(controller.deleteHandler);` `}` `}` # Project Routes Let us modify `**projects_controller.ts**` and `**projects_routes.ts**` files, respectively, as follows: `// project_controller.ts` `export class ProjectController {` `public addHandler() {` `// addHandler` `}` `public getAllHandler() {` `// getAllHandler` `}` `public getDetailsHandler() {` `// getDetailsHandler` `}` `public async updateHandler() {` `// updateHandler` `}` `public async deleteHandler() {` `// deleteHandler` `}` `}` `// projects_routes.ts` `import { Express } from 'express';` `import { ProjectController } from './projects_controller';` `export class ProjectRoutes {` `private baseEndPoint = '/api/projects';` `constructor(app: Express) {` `const controller = new ProjectController();` `app.route(this.baseEndPoint)` `.get(controller.getAllHandler)` `.post(controller.addHandler);` `app.route(this.baseEndPoint + '/:id')` `.get(controller.getDetailsHandler)` `.put(controller.updateHandler)` `.delete(controller.deleteHandler);` `}` `}` # Task Routes Let us modify `**tasks_controller.ts**` and `**tasks_routes.ts**` files, respectively, as follows: `// tasks_controller.ts` `export class TaskController {` `public addHandler() {` `// addHandler` `}` `public getAllHandler() {` `// getAllHandler` `}` `public getDetailsHandler() {` `// getDetailsHandler` `}` `public async updateHandler() {` `// updateHandler` `}` `public async deleteHandler() {` `// deleteHandler` `}` `}` `// tasks_routes.ts` `import { Express } from 'express';` `import { TaskController } from './tasks_controller';` `export class TaskRoutes {` `private baseEndPoint = '/api/tasks';` `constructor(app: Express) {` `const controller = new TaskController();` `app.route(this.baseEndPoint)` `.get(controller.getAllHandler)` `.post(controller.addHandler);` `app.route(this.baseEndPoint + '/:id')` `.get(controller.getDetailsHandler)` `.put(controller.updateHandler)` `.delete(controller.deleteHandler);` `}` `}` # Comment Routes Let us modify `**comments_controller.ts**` and `**comments_routes.ts**` files, respectively, as follows: `// comments_controller.ts` `export class CommentController {` `public addHandler() {` `// addHandler` `}` `public getAllHandler() {` `// getAllHandler` `}` `public getDetailsHandler() {` `// getDetailsHandler` `}` `public async updateHandler() {` `// updateHandler` `}` `public async deleteHandler() {` `// deleteHandler` `}` `}` `// comments_routes.ts` `import { Express } from 'express';` `import { CommentController } from './comments_controller';` `export class CommentRoutes {` `private baseEndPoint = '/api/comments';` `constructor(app: Express) {` `const controller = new CommentController();` `app.route(this.baseEndPoint)` `.get(controller.getAllHandler)` `.post(controller.addHandler);` `app.route(this.baseEndPoint + '/:id')` `.get(controller.getDetailsHandler)` `.put(controller.updateHandler)` `.delete(controller.deleteHandler);` `}` `}` We define all routes based on components individually. However, these routes need to be initialized from the Express server, so import all routes in one file and call it from the Express server. Create `**routes**` directory and `**index.ts**` with the following code: `// index.ts` ```js` `import { Express, Router } from 'express';` `import { RoleRoutes } from '../components/roles/roles_routes';` `import { UserRoutes } from '../components/users/users_routes';` `import { ProjectRoutes } from '../components/projects/projects_routes';` `import { TaskRoutes } from '../components/tasks/tasks_routes';` `import { CommentRoutes } from '../components/comments/comments_routes';` `export class Routes {` `public router: Router;` `constructor(app: Express) {` `const routeClasses = [` `RoleRoutes,` `UserRoutes,` `ProjectRoutes,` `TaskRoutes,` `CommentRoutes` `];` `for (const routeClass of routeClasses) {` `try {` `new routeClass(app);` ``console.log(`Router : ${routeClass.name} - Connected`);`` `} catch (error) {` ``console.log(`Router : ${routeClass.name} - Failed`);`` `}` `}` `}` `}` Afterward, modify `**express_server.ts**` with import `**routes**` in the `app`: `// express_server.ts` ``` `import expressfrom 'express';` `import { IServerConfig } from './utils/config';` `import * as config from '../server_config.json';` `import { Routes } from './routes';` `export class ExpressServer {` `private static server = null;` `public server_config: IServerConfig = config;` `constructor() {` `const port = this.server_config.port ?? 3000;` `// initialize express app` `const app = express();` `app.use(bodyParser.urlencoded({ extended: false }));` `app.use(bodyParser.json());` `app.get('/ping', (req, res) => {` `res.send('pong');` `});` `const routes = new Routes(app);` `if (routes) {` `console.log('Server Routes started for server');` `}` `ExpressServer.server = app.listen(port, () => {` ``console.log(`Server is running on port ${port} with pid =`` ``${process.pid}`);`` `});` `}` `//close the express server for safe on uncaughtException` `public closeServer(): void {` `ExpressServer.server.close(() => {` `console.log('Server closed');` `process.exit(0);` `});` `}` `}` While running the application, the following output will display in the terminal: Output: `Router : RoleRoutes - Connected` `Router : UserRoutes - Connected` `Router : ProjectRoutes - Connected` `Router : TaskRoutes - Connected` `Router : CommentRoutes - Connected` `Server Routes started for server` `Server is running on port 5000 with pid = 251784` `Connected to the database` # Conclusion At this moment, now our server is running with a database utility, all entities along with their routes. We learned about the use of the cluster module, using TypeORM for our database connection and query needs. We have our entities with all required columns. As of now, we have empty and un-implemented route functions. In the next chapters, we will take each entity and implement the API. # Multiple Choice Questions 1. What is the primary goal of any project management software, including the PMS (Project Management System) discussed here? 1. To develop web applications 2. To allow individuals and teams to plan and execute tasks 3. To organize data effectively 4. To provide secure user login 2. Which modules are considered must-have in the PMS system based on the defined tasks? 1. User Module, Project Module, Task Module, and Comment Module 2. Planning Module, API Development Module, and Unit Testing Module 3. Communication Module, Deployment Module, and Database Design Module 4. API Caching Module, Project Reports Module, and User Profile Module 3. What is the primary objective of normalization in database design? 1. To increase data redundancy 2. To maintain data integrity and avoid data duplication 3. To slow down query performance 4. To increase the complexity of queries 4. What role does the Role Table play in the PMS database? 1. It defines the rights and permissions of users 2. It stores project-related data 3. It manages user profiles 4. It records project creation and modification times 5. Which file is typically generated as a result of running `**"**npm init**"**` for a Node.js project? 1. `package-lock.json` 2. `tsconfig.json` 3. `server_config.json` 4. `package.json` 6. What does the `**"**tsconfig.json**"**` file specify in a TypeScript project? 1. The project’s description 2. The project’s dependencies 3. TypeScript compiler options and settings 4. The project’s test commands 7. Which of the following decorators from TypeORM is used to mark a class as a TypeORM entity representing a database table? 1. `@Entity()` 2. `@Table()` 3. `@Database()` 4. `@Model()` 8. Which TypeScript feature is used to define and enforce specific predefined values for the `**"**status**"**` and `**"**priority**"**` columns in the `**"**Tasks**"**` entity? 1. Type assertions 2. Type inference 3. Enums 4. Generics 9. What is the purpose of the `@JoinColumn` decorator in the `**"**Tasks**"**` entity class? 1. To specify the name of the entity class 2. To define a foreign key constraint 3. To specify the name of the foreign key column 4. To create a new table in the database 10. How would you trigger the update route for modifying existing data in Express.js? 1. Send an HTTP POST request to a path like `**"**/api/componentname**"**` 2. Send an HTTP PUT request to a path like `**"**/api/componentname/:id**"**` 3. Send an HTTP GET request to a path like `**"**/api/componentname**"**` 4. Send an HTTP DELETE request to a path like `**"**/api/componentname/:id**"**` # Answers 1. b 2. a 3. b 4. a 5. d 6. c 7. a 8. c 9. c 10. b # Further Reading [`www.postgresqltutorial.com`](https://www.postgresqltutorial.com) [`typeorm.io`](https://typeorm.io) [`www.npmjs.com/package/pg`](https://www.npmjs.com/package/pg) ```js ```` ```js`` ``````js ``````js` ``````js`` ``````js``` ``````js```` ```jsjs ``````js`````` ```js```````js``` ``````js```````js js```````js`` jsjs``` ``````jsjs````

第五章

用户模块的 REST API

简介

任何应用程序的核心都是用户模块,这是一个基础组件,负责管理以用户为中心的功能。此模块允许用户管理用户账户,启用身份验证和授权,以及各种用户特定操作,如添加或注册用户、更新用户资料、删除用户、密码管理、基于角色的权限、日志记录等。用户模块提升了用户体验并促进了应用程序的无缝运行。

结构

在本章中,我们将讨论以下主题:

  • 基础控制器和基础服务用于 REST API 开发

  • 带输入验证的角色管理

  • 带输入验证的用户管理

    • 用户入职

    • 用户登录

    • 认证和授权机制

    • 更新用户数据

    • 删除用户账户

    • 带电子邮件通知的密码管理和恢复

基础控制器

在上一章中,我们看到了如何为每个模块构建单独的控制器。对于每个控制器,都有一些常见的方法,例如添加、获取所有、获取单个、更新等处理程序。由于每个实体都有一个控制器,因此这些通用函数必须由每个控制器实现。我们可以编写一个抽象类供控制器扩展,并强制它们提供实现。因此,遵循既定规范,我们现在介绍一个基础概念,称为 基础控制器。它是一个抽象类,作为其他类继承其预定义方法的蓝图,这些方法有助于创建、更新、检索所有、检索单个和删除数据。

让我们在 utils 目录中使用以下代码创建 **base_controller.ts**

// base_controller.ts

import { Request, Response } from 'express';

export abstract class BaseController {

public abstract addHandler(req: Request, res: Response): void;

public abstract getAllHandler(req: Request, res: Response): void;

public abstract getOneHandler(req: Request, res: Response): void;

public abstract updateHandler(req: Request, res: Response): void;

public abstract deleteHandler(req: Request, res: Response): void;

}

这个抽象类的目的是提供一个统一的框架,其他类(控制器)在实现这些方法时可以遵循。当一个类扩展了 **BaseController** 时,它必须提供这些抽象方法的实现。这种做法保证了跨各种路由的控制器保持一致的方法名称和参数,尽管具体的执行细节可能有所不同。

请注意,单个控制器仍然可以编写自己的额外方法。

本章的代码可以从github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/tree/main/ch-05-code-files下载。

基础服务

基础服务是一个基础结构,为应用程序中管理数据操作提供常用功能。它作为蓝图,其他服务可以继承以避免重复代码并确保数据操作的一致模式。基础服务的主要目的是封装常用的数据操作,如创建、读取、更新和删除,并将它们提供给其他服务。这减少了代码重复并强制执行应用程序不同模块中的一致实践。需要数据操作的其他服务可以继承基础服务。通过扩展基础服务,这些子服务可以访问基础服务中定义的常用方法。

让我们使用以下代码创建**base_service.ts**

[github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/base_service.ts](https://github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/base_service.ts)

如果您已经下载了本章的源代码,那么您可以在其中找到**base_service.ts**。我们为每个方法添加了注释,以解释其功能和工作原理。这个基础服务类提供了常见的 CRUD 操作、**findbyIds**以及自定义查询执行器,同时处理 API 响应和错误情况。这个服务类可以被其他服务继承。

管理数据库连接效率非常重要。如果我们为每个操作创建单独的数据库连接,那么在高负载期间应用程序可能会崩溃。使用数据库池是最佳实践,以限制和重用连接池中的连接。

让我们使用以下代码更改**db.ts**文件,以添加数据库池并在整个应用程序中使用单个连接:

[github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/db.ts](https://github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/db.ts)

**connectDatabase**函数负责建立数据库连接,如果可用则返回现有连接。它首先检查是否已经存在有效的连接,如果没有,则初始化一个新的连接并将其存储以供将来使用。

**getInstance** 函数检索数据库实例,确保在提供访问之前连接已经建立。与 **connectDatabase** 函数不同,**getInstance** 在返回之前会等待连接建立,确保它只能在连接就绪后使用一次。

**getRepository** 函数旨在为给定实体检索存储库实例。它会检查是否存在有效的数据库连接,并在存储库实例不存在时创建它。如果没有有效的连接,它返回 null。

角色管理

角色管理是应用程序安全的一个关键方面,它涉及控制和定义系统内用户的访问和权限。它确保用户根据其角色或职责执行特定操作时拥有适当的权利。角色管理对于防止未授权访问和数据泄露以及维护应用程序的整体完整性至关重要。

不同用户有不同的访问级别和权限。例如,一个应用程序可能有不同的用户角色,如 **超级管理员****项目经理****访客**,每个角色都有不同的权限集。管理员通常可以访问所有功能和功能,项目经理有有限的访问权限,而访客可能有非常受限的访问权限。

角色服务

角色服务将用于在继承自基础服务的数据库中执行基于角色的操作。因此,让我们在角色组件中使用以下代码创建 **roles_service.ts** 文件:

// role_service.ts

import { Repository } from 'typeorm';

import { BaseService } from '../../utils/base_service';

import { DatabaseUtil } from '../../utils/db';

import { Roles } from './roles_entity';

export class RolesService extends BaseService<Roles> {

constructor() {

// 创建 DatabaseUtil 的实例

const databaseUtil = new DatabaseUtil();

// 获取 Roles 实体的存储库

const roleRepository: Repository<Roles> = databaseUtil.getRepository(Roles);

// 调用 BaseService 类的构造函数,并将

repository 作为参数

super(roleRepository);

}

}

**RolesService** 类旨在扩展 **BaseService** 类提供的功能。它使用 **DatabaseUtil** 类获取 **Roles** 实体的存储库,然后将该存储库传递给 **BaseService** 类的构造函数。这使得 **RolesService** 类能够继承并使用在 **BaseService** 类中定义的 CRUD 方法来处理 **Roles** 实体。

我们将按以下方式开发角色的 REST API:

  • 添加角色

  • 获取所有角色

  • 获取单个角色

  • 更新角色

  • 删除角色

在开发实际的 添加角色 API 之前,我们需要在将角色添加到数据库时定义输入验证。因此,让我们使用 Express Validator 来验证输入请求。

输入验证

首先,安装 Express Validator 模块,请在 **cmd** 中粘贴以下命令:

npm i express-validator --save

现在在 utils 目录中创建名为 **validator.ts** 的文件,使用以下代码:

[github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/validator.ts](https://github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/validator.ts)

提供的代码导出一个名为 **validate** 的函数,该函数使用 express-validator 包生成用于验证请求数据的 **middleware**。此中间件函数运行提供的验证函数,使用 **validationResult** 检查验证错误,如果验证失败,则发送带有 400 状态和错误消息的响应。错误消息的结构与 **IValidationError** 接口相匹配。这种方法通常用于处理 Express 应用程序中的请求验证。

接下来,在 **roles_routers.ts** 文件中创建 **validRoleInput**,使用以下代码:

// roles_routers.ts

import { Express } from 'express';

import { RoleController, RolesUtil } from './roles_controller';

import { validate } from '../../utils/validator';

import { body } from 'express-validator';

const validRoleInput = [

body('name').trim().notEmpty().withMessage('It should be required'),

body('description').isLength({ max: 200 }).withMessage('It has

maximum limit of 200 characters'),

];

export class RoleRoutes {

private baseEndPoint = '/api/roles';

constructor(app: Express) {

const controller = new RoleController();

app.route(this.baseEndPoint)

.get(controller.getAllHandler)

.post(validate(validRoleInput), controller.addHandler);

app.route(this.baseEndPoint + '/:id')

.get(controller.getOneHandler)

.put(validate(validRoleInput), controller.updateHandler)

.delete(controller.deleteHandler);

}

}

在前面的类中使用了 **baseEndPoint** 变量。这是 API 端点的一部分,对于所有角色 API 都将是相同的。

注意到 **validRoleInput** 变量,它是一个数组。这个数组包含了对角色中预期每个输入字段的一系列验证检查。这个数组的每个元素都是一个验证函数,用于检查数据的特定方面。

请求体中 **name** 字段的验证器将按以下方式处理:

body('name').trim().notEmpty().withMessage('It should be required')

此验证器应用于请求体中的 **name** 字段,并执行以下功能:

  • **trim()**: 从输入中删除任何前导和尾随空白字符。

  • **notEmpty()**: 检查输入是否为空。

  • **withMessage('It should be required')**: 如果验证失败,此消息将包含在错误响应中。

类似地,**description**字段的验证器如下所示:

body('description').isLength({ max: 200 }).withMessage('It has a maximum limit of 200 characters')

此验证器应用于请求体中的**description**字段。它检查输入的长度不超过**200**个字符。

总体而言,此代码定义了一组针对角色数据不同字段的验证规则,包括检查访问权限的存在、长度和有效性。如果任何验证失败,相应的错误消息将包含在状态码为**400: bad**的错误响应中。

每个角色都包含一组权限。这些权限不过是字符串键,这有助于我们了解登录用户是否被分配了特定的权限,以便让他们执行相应的任务。添加任务的权限可能非常简单,如**add_task**,这可以添加到分配给用户的角色中。

让我们在**common.ts**文件中定义所有必要的应用权限,如下所示:

[github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/common.ts](https://github.com/ava-orange-education/Hands-on-Rest-APIs-with-Node.js-and-Express/blob/main/ch-05-code-files/pms-be/src/utils/common.ts)

这些权限是应用权限,用于根据分配的角色在用户登录时检查权限。当创建角色时,它有一个权限值,这些应用权限以逗号分隔的形式保存。

让我们在**role_controller.ts**文件中使用以下代码创建一个包含**getAllPermissionsFromRights**函数的**RolesUtil**类:

// role_controller.ts

export class RolesUtil {

/**

* 从定义的权限对象中的权限中检索所有可能的权限。

* @returns {string[]} An array of permissions

*/

public static getAllPermissionsFromRights(): string[] {

// 初始化一个空数组以收集值;

let permissions = [];

// 遍历 Rights 对象的每个部分

for (const module in Rights) {

// 检查当前模块是否定义了 ALL 的权限

if (Rights[module]['ALL']) {

let sectionValues = Rights[module]['ALL'];

sectionValues = sectionValues.split(',');

permissions = […permissions, …sectionValues];

}

}

// 返回收集到的权限

return permissions;

}

}

此函数有效地编译了从定义的权限对象中可用的所有权限,然后可以在应用程序中用于验证和其他目的。

现在请使用以下代码在**validRoleInput**中的**rights**字段添加验证:

const validRoleInput = [

body('name').trim().notEmpty().withMessage('It should be required'),

body('description').isLength({ max: 200 }).withMessage('It has maximum limit of 200 characters'),

body('rights').custom((value: string) => {

const accessRights = value?.split(',');

if (accessRights?.length > 0) {

const validRights = RolesUtil.getAllPermissionsFromRights();

const areAllRightsValid = accessRights.every(right =>

validRights.includes(right));

if (!areAllRightsValid) {

throw new Error('无效权限');

}

}

return true; // 验证通过

})

];

根据提供的代码,自定义验证函数确保在添加或更新角色过程中权限的有效性。它评估请求中接收到的权限,并验证它们是否有效。如果发现任何权限无效,将生成错误。

添加角色

当使用 REST API 添加角色时,你通常会在请求体中提供必要的数据,例如角色的名称、描述以及它应该拥有的权限。负责此操作的 API 端点旨在接收这些数据,根据预定义的规则进行验证,并根据提供的信息创建新的角色。

我们已经创建了**roles_controller.ts**作为骨架类。现在,让我们使用扩展的 Base Controller 来修改它,并使用基础服务通过以下代码执行数据库操作:

// roles_controller.ts

import { Response, Request } from 'express';

import { RolesService } from './roles_service';

import { BaseController } from '../../utils/base_controller';

import { Rights } from '../../utils/common';

export class RoleController extends BaseController {

public async addHandler(req: Request, res: Response): Promise<void> {

const role = req.body;

const service = new RolesService();

const result = await service.create(role);

res.status(result.statusCode).json(result);

return;

}

js```````js` `public async getAllHandler(req: Request, res: Response) {}` `public async getOneHandler(req: Request, res: Response) {}` `public async updateHandler(req: Request, res: Response) {}` `public async deleteHandler(req: Request, res: Response) {}` `}` In this context, the ``**addHandler**`**`** method captures the data provided within the incoming request body. Subsequently, it forwards this data to the Base service by invoking the create method, which is responsible for adding the information to the database. The Base service then returns a response to this handler, signifying either a successful or unsuccessful outcome. This response is effectively transmitted as the ultimate outcome for the associated REST API operation. Now call `**addHandler**` in role routes with change in `**roles_router.ts**` file as follows: `//roles_router.ts` `export class RoleRoutes {` `private baseEndPoint = '/api/roles';` `constructor(app: Express) {` `const controller = new RoleController();` `app.route(this.baseEndPoint)` `.post(validate(validRoleInput), controller.addHandler);` `}` We have established routes for adding roles and incorporated middleware to validate requests before inserting data into the database. You can test the REST APIs in Postman, cURL, or .http file with the following request and get their responses. The easiest method is to simply use VSCode, install REST Client extension, and create a new file with the .http extension and put the request as shown in *Figure 5.1*: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/5.1.jpg) **Figure 5.1:** Making a request using VSCode REST Client Extension For testing the API for adding a role, we need to make a `**POST**` call to `**/api/roles**`. The preceding figure shows the way it can be called with the request body. **REST API Add Role** **Request** `URL : http://127.0.0.1:3000/api/roles` `Method: POST` `body :` `{` `"name":"Super Admin",` `"description":"Having All Rights",` `"rights": "add_role,edit_role,get_all_roles,get_details_role,` `delete_role,` `add_user,edit_user,get_all_users,get_details_user,delete_user,` `add_project,edit_project,get_all_projects,get_details_project,` `delete_project,add_task,edit_task,get_all_tasks,get_details_task,` `delete_task,add_comment,edit_comment,get_all_comments,` `get_details_comment,delete_comment"` `}` **Response** `{` `"statusCode": 201,` `"status": "success",` `"data": {` `"role_id": "b88cc70d-ab0a-4464-9562-f6320df519f6",` `"name": "Super Admin",` `"description": "Having All Rights",` `"rights": "add_role,edit_role,get_all_roles,get_details_role,` `delete_role,add_user,edit_user,get_all_users,get_details_user,` `delete_user,add_project,edit_project,get_all_projects,` `get_details_project,delete_project,add_task,edit_task,get_all_tasks,` `get_details_task,delete_task,add_comment,edit_comment,` `get_all_comments,get_details_comment,delete_comment",` `"created_at": "2023-08-16T16:39:14.047Z",` `"updated_at": "2023-08-16T16:39:14.047Z"` `}` `}` In case of a unique role name, trying again with the same request gives an error as 409 conflict code: `{` `"statusCode": 409,` `"status": "error",` `"message": "Key (name)=(Super Admin) already exists."` `}` In another case, if you change the rights as "`**rights**`"`**:**`"`**no_rights**`", it gives an error for Bad Request with 400 status as follows: `{` `"statusCode": 400,` `"status": "error",` `"errors": [` `{` `"rights": "Invalid permission"` `}` `]` `}` # GetAll Roles Once a role has been successfully added to the database, we can proceed to retrieve the newly inserted roles from the database. So, let's change the `**getAllHandler**` method in the `**roles_controller.ts**` file using the following code: `// roles_controller.ts` `public async getAllHandler(req: Request, res: Response): Promise<void> {` `const service = new RolesService();` `const result = await service.findAll(req.query);` `res.status(result.statusCode).json(result);` `}` The ``**getAllHandler**`**`** method uses the `**RolesService**` class to retrieve all roles from the database based on the query parameters in the request. The resulting data is then sent back to the client with an appropriate HTTP status code and formatted as JSON. This `**controller**` method call in routes with change in `**roles_routes.ts**` as follows: `// roles_routes.ts` `app.route(this.baseEndPoint)` `.post(validate(validRoleInput), controller.addHandler)` `.get(controller.getAllHandler);` By employing this approach, we establish a `**GET**` route that fetches all roles stored in the database, effectively functioning as a REST API endpoint for retrieving role data. **REST API GetAll Roles** **Request** `URL : http://127.0.0.1:3000/api/roles` `Method: GET` `Query Params: {}` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": [` `{` `"role_id": "b88cc70d-ab0a-4464-9562-f6320df519f6",` `"name": "Super Admin",` `"description": "Having All Rights",` `"rights": "add_role,edit_role,get_all_roles,get_details_role,delete_role,add_user,edit_user,get_all_users,get_details_user,` `delete_user,add_project,edit_project,get_all_projects,` `get_details_project,delete_project,add_task,edit_task,` `get_all_tasks,get_details_task,delete_task,add_comment,` `edit_comment,get_all_comments,get_details_comment,delete_comment",` `"created_at": "2023-08-16T16:39:14.047Z",` `"updated_at": "2023-08-16T16:39:14.047Z"` `}, {` `"role_id": "5f11a06b-e9a7-438d-9f49-757e8239e238",` `"name": "visitor",` `"description": null,` `"rights": null,` `"created_at": "2023-08-15T13:04:50.314Z",` `"updated_at": "2023-08-15T13:04:50.314Z"` `}` `]` `}` If you want to filter or search by exact name, you can change query params as follows: `URL : http://127.0.0.1:5000/api/roles?name=visitor` ``````js```` `It gives only matched data as a response, as follows` js`````` { "statusCode": 200, "status": "success", "data": [ { "role_id": "5f11a06b-e9a7-438d-9f49-757e8239e238", "name": "visitor", "description": null, "rights": null, "created_at": "2023-08-15T13:04:50.314Z", "updated_at": "2023-08-15T13:04:50.314Z" } ] } 注意:在基础服务中,查询参数现在将仅适用于精确匹配。我们将在稍后探索搜索功能。 # 获取单个角色 获取单个角色端点是角色管理系统的一个基本部分,允许用户查看特定角色的信息,而无需检索所有角色的完整列表。这对于提供针对每个角色属性和权限的针对性见解至关重要。要实现获取单个角色 API,请在角色控制器中的**getOneHandler**代码中进行以下更改: // roles_controller.ts public async getOneHandler(req: Request, res: Response): Promise<void> { const service = new RolesService(); const result = await service.findOne(req.params.id); res.status(result.statusCode).json(result); } **getOneHandler**函数作为传入客户端请求、与数据库交互的服务层和传出 HTTP 响应之间的桥梁。它根据提供的角色 ID 从数据库中检索单个角色的详细信息,并将角色信息发送回客户端。 该方法通过在路由文件中创建一个新的路由来从路由文件中调用: // roles_routes.ts app.route(this.baseEndPoint + '/:id') .get(controller.getOneHandler); 这里****,**/:id**将是一个请求参数,用于捕获用户想要检索的角色 ID。 REST API 获取单个角色 请求 URL: http://127.0.0.1:3000/api/roles/5f11a06b-e9a7-438d-9f49-757e8239e238 方法:GET 响应 { "statusCode": 200, "status": "success", "data": { "role_id": "5f11a06b-e9a7-438d-9f49-757e8239e238", "name": "visitor", "description": null, "rights": null, "created_at": "2023-08-15T13:04:50.314Z", "updated_at": "2023-08-15T13:04:50.314Z" } } 提供有效的角色 ID 将产生成功响应,而输入一个与现有数据库条目不对应的 ID 将导致**404 错误**,表示请求的实体未找到。 { "statusCode": 404, "status": "error", "message": "Not Found" } # 更新角色 更新角色涉及修改存储在数据库中的特定角色的现有数据。此过程允许您调整角色的属性,例如其名称、描述和关联的权限。通过执行更新,您可以确保角色的信息保持准确和最新。当角色的权限发生变化时,此操作特别有用,您需要将这些更改反映在数据库中。 要实现更新角色 API,请在角色控制器中的**updateHandler**代码中进行以下更改: // roles_controller.ts public async updateHandler(req: Request, res: Response) : Promise<void> { const role = req.body; const service = new RolesService(); const result = await service.update(req.params.id, role); res.status(result.statusCode).json(result); } **updateHandler**函数旨在处理数据库中角色的更新。 它通过从传入的 HTTP 请求体中检索数据来操作,然后利用这些数据根据请求参数 ID(即角色的唯一标识符**role_id**)更新数据库中的相应角色数据。该函数随后生成一个响应,指示更新操作是成功还是失败,如果需要,提供有关更新数据或适当的错误消息。 该方法通过在路由文件中创建一个新的路由来从路由文件中调用: // roles_routes.ts app.route(this.baseEndPoint + '/:id') `.

第六章

项目与任务模块的 REST API

简介

在项目管理系统中(PMS),“项目模块”通常指的是一个特定的组件或核心模块。这些模块旨在简化项目规划、执行和监控的各个方面,使项目经理和团队能够更有效地管理复杂项目。

每个项目模块通常专注于特定领域,例如任务管理、资源分配、时间跟踪或报告,提供专门的工具和功能来支持这些功能。这些模块是 PMS 的重要组成部分,使用户能够根据其项目的具体需求定制项目管理方法。任务模块在项目管理中发挥着关键作用,它提供了一种集中和有序的方法来管理和执行项目中的任务。它增强了协作,提高了对任务进度的可见性,并有助于确保项目按时按范围完成。

结构

在本章中,我们将讨论以下主题:

  • 带输入验证的项目管理

    • 带分配用户的创建项目 API

    • 项目更新、列表、项目详情和删除 API

  • 带输入验证的任务管理

    • 带分配用户的创建任务 API

    • 任务更新、列表、任务详情和删除 API

项目管理

项目模块作为一个集中平台,使项目经理和团队能够高效地规划、执行和完成项目,同时保持控制、透明度和协作。

项目服务

我们之前在第四章,规划应用中定义了项目实体,因此让我们首先在项目目录中创建名为**projects_service.ts**Project Service,以下为相关代码:

import { Repository } from 'typeorm';

import { BaseService } from '../../utils/base_service';

import { DatabaseUtil } from '../../utils/db';

import { Projects } from './projects_entity';

export class ProjectsService extends BaseService<Projects> {

constructor() {

let projectRepository: Repository<Projects> | null = null;

projectRepository = new DatabaseUtil().getRepository(Projects);

super(projectRepository);

}

}

**ProjectsService**类继承自**BaseService**,并使用 TypeORM 与数据库中的**Projects**实体一起工作。该实体的存储库是从**DatabaseUtil**类中获得的。该服务结构旨在简化对**Projects**实体的数据库操作,例如创建、检索、更新和删除记录。

我们将按以下方式开发项目的 REST API:

  • **添加** 项目

  • **获取所有** 项目

  • **获取单个** 项目

  • **更新** 项目

  • **删除** 项目

输入验证

在项目模块中进行的输入验证在确保项目管理系统中使用的数据的完整性和可靠性方面发挥着关键作用。它涉及检查和验证来自各种来源的用户输入或接收到的数据,以确保其符合预定义的标准并满足所需的标准。

在 utils 目录下的**common.ts**文件中添加以下函数,**checkValidDate**,用于验证日期:

export const **checkValidDate** = function (value) {

if (!moment(value, 'YYYY-MM-DD HH:mm:ss', true).isValid()) {

return false;

}

return true;

};

现在,在**projects_routes.ts**文件中添加**validProjectInput**,代码如下:

import { Express } from 'express';

import { ProjectController } from './projects_controller';

import { body } from 'express-validator';

import { validate } from '../../utils/validator';

import moment from 'moment';

import { authorize } from '../../utils/auth_util';

import { checkValidDate } from '../../utils/common'

const **validProjectInput** = [

body('name').trim().notEmpty().withMessage('It should be required'),

body('user_ids').isArray().withMessage('It should be ids of users array'),

body('start_time').custom((value) => {

if (!**checkValidDate**(value)) {

throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss');

}

const startTime = new Date(value);

const currentTime = new Date();

if (startTime <= currentTime) {

throw new Error('Start time must be greater than the current time');

}

return true;

}),

body('end_time').custom((value, { req }) => {

if (!**checkValidDate**(value)) {

throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss');

}

const startTime = new Date(req.body.start_time);

const endTime = new Date(value);

if (endTime <= startTime) {

throw new Error('End time must be greater than the start time');

}

return true;

})

];

这里是前面代码关键部分的详细信息:

  • **validProjectInput** 数组:此数组包含对项目中预期每个输入字段的一系列验证检查。数组的每个元素都是一个验证函数,用于检查数据的特定方面。

  • **body('name').trim().notEmpty().withMessage('It should be required')**:此验证请求体中的名称字段,**.trim()**从输入中删除任何前导或尾随空白字符;**.notEmpty()**检查该字段是否不为空;**.withMessage('It should be required')**在验证失败时提供自定义错误消息,指出**'name'**字段是必需的。

  • **body('user_ids').isArray().withMessage('It should be ids of users array')**:此验证请求体中的**user_ids**字段,**.isArray()**检查该字段是否为数组;**.withMessage('It should be ids of users array')**在验证失败时提供自定义错误消息,指定**user_ids**字段应该是用户 ID 的数组。

  • **body('start_time').custom((value) => { /* … */ })**: 这使用自定义验证函数验证请求体中的 start_time 字段。自定义验证函数检查值(即 '**start_time**' 的值)是否在有效的日期格式(**YYYY-MM-DD HH:mm:ss**)中。然后它将 '**start_time**' 与当前时间进行比较,确保 '**start_time**' 大于当前时间。如果这些检查中的任何一个失败,它将抛出一个带有自定义消息的错误。

  • **body('end_time').custom((value, { req }) => { /* … */ })**: 这使用另一个自定义验证函数验证请求体中的 **end_time** 字段。类似于之前的自定义函数,它检查值(即 '**end_time**' 的值)是否在有效的日期格式(**YYYY-MM-DD HH:mm:ss**)中。它还访问 **req.body.start_time** 来比较 '**end_time**' 与 '**start_time**',以确保 '**end_time**' 大于 '**start_time**'。如果这些检查中的任何一个失败,它将抛出一个带有自定义消息的错误

添加项目

当使用 REST API 添加项目时,你通常在请求体中提供所需的数据,包括项目名称、描述以及任何相关属性。为此任务指定的 API 端点专门设计用于接受并验证这些数据,根据预定义的标准,最终基于提供的信息创建一个新项目。

我们之前已经创建了一个作为骨架类的 **projects_controller.ts**。现在,让我们使用扩展的基控制器来更改它,并使用基础服务执行以下代码进行数据库操作:

import { Response, Request } from 'express';

import { hasPermission } from '../../utils/auth_util';

import { ProjectsService } from './projects_service';

import { UsersUtil } from '../users/users_controller';

export class ProjectController extends BaseController {

/**

* 处理新用户的添加。

* @param {object} req - 请求对象。

* @param {object} res - 响应对象。

*/

public async addHandler(req: Request, res: Response): Promise<void> {

if (!hasPermission(req.user.rights, 'add_project')) {

res.status(403).json({ statusCode: 403, status: 'error', message: '未授权' });

return;

}

try {

// 创建 ProjectService 的实例

const service = new ProjectsService();

// 从请求体中提取项目数据

const project = req.body;

// 检查提供的 user_ids 是否有效

const isValidUsers = await UsersUtil.checkValidUserIds

(project.user_ids);

if (!isValidUsers) {

// 如果 user_ids 无效,发送错误响应

res.status(400).json({ statusCode: 400, status: 'error', message: '无效的用户 IDs' });

return;

}

// 如果 user_ids 有效,创建用户

const createdProject = await service.create(project);

res.status(201).json(createdProject);

} catch (error) {

// 处理错误并发送适当的响应

console.error(`添加用户时出错 => ${error.message}`);

res.status(500).json({ statusCode: 500, status: 'error', message: '内部服务器错误' });

}

}

js```` `public async getAllHandler(req: Request, res: Response) {}` `public async getOneHandler(req: Request, res: Response) {}` `public async updateHandler(req: Request, res: Response) {}` `public async deleteHandler(req: Request, res: Response) {}` `}` 在`**UserUtil**`类中添加`**checkValidUserIds function**`,以下代码用于检查给定的`**userIds**`是否有效且存在于数据库中: `public static async checkValidUserIds(user_ids: string[]) {` `const userService = new UsersService();` `// 查询数据库以检查所有 user_ids 是否有效` `const users = await userService.findByIds(user_ids);` `// 检查所有 user_ids 是否在数据库中找到` `return users.data.length === user_ids.length;` `}` 在此上下文中,`**addHandler**`方法获取传入请求体中的数据。然后通过调用创建方法将此数据传输到基础服务,该创建方法负责将此信息纳入数据库。此外,我们还包括在项目创建过程中将用户添加到项目中的功能,将用户与项目关联。 现在,让我们在`**projects_router.ts**`文件中调用`**addHandler**`,如下所示: `export class ProjectRoutes {` `private baseEndPoint = '/api/projects';` `constructor(app: Express) {` `const controller = new ProjectController();` `**app.route(this.baseEndPoint)**` `**.all(authorize)**` `**.post(validate(validProjectInput), controller.addHandler);**` `}` `}` 我们已经为添加项目建立了路由,并在将数据插入数据库之前添加了中间件来验证请求。 一旦 API 成功触发,以下数据将被添加到 PostgreSQL 数据库中: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.1.jpg) **图 6.1:Postgres 添加项目输出** **REST API 添加项目** **请求** `URL : http://127.0.0.1:3000/api/projects` `方法: POST` `body :` `{` `"name":"项目管理",` `"description":"此项目是关于管理的",` `"User_ids": ["b930d02c-43af-4875-b7e9-546c9f4c23dd",` `"611b346e-be39-4a7e-96d1-e7421193bd5a", "d166945a-f85d-485c-bdac-0c8056b3188a"],` `"start_time":"2023-09-25 00:00:00",` `"end_time":"2023-12-15 00:00:00"` `}` **响应** `{` `"statusCode": 201,` `"status": "success",` `"data": {` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"name": "项目管理",` `"description": "此项目是关于管理的",` `"user_ids": [` `"b930d02c-43af-4875-b7e9-546c9f4c23dd",` `"611b346e-be39-4a7e-96d1-e7421193bd5a",` `"d166945a-f85d-485c-bdac-0c8056b3188a"` `],` `"start_time": "2023-09-25 00:00:00",` `"end_time": "2023-12-15 00:00:00",` `"created_at": "2023-09-23T17:23:36.061Z",` `"updated_at": "2023-09-23T17:23:36.061Z"` `}` `}` `}` ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.2.jpg) **图 6.2:Postman 添加项目数据的响应 201** 如果尝试使用相同的请求添加具有唯一项目名称的项目,则会收到一个`**409**`冲突代码的错误: `{` `"statusCode": 409,` `"status": "error",` `"message": "键(name)=(项目管理)已存在。"` `}` ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.3.jpg) **图 6.3:Postman 已存在项目响应 409** 在另一种情况下,如果您将`**start_time**`更改为早于当前时间,则会收到一个`**400**`状态码的 Bad Request 错误,如下所示: `{` `"statusCode": 400,` `"status": "error",` `"errors": [` `{` `"rights": "开始时间必须晚于当前时间"` `}` `]` `}` ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.4.jpg) **图 6.4:Postman 给项目提供无效日期的响应 400** # GetAll Project 在成功将项目添加到数据库后,下一步是从数据库中检索新插入的项目。为此,更新`**projects_controller.ts**`文件中的`**getAllHandler**`方法,如下所示: `public async **getAllHandler**(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'get_all_projects')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: '未授权' });` `return;` `}` js`const service = new ProjectsService();` `const result = await service.findAll(req.query);` `for (const project of result.data) {` `project['users'] = await UsersUtil.getUsernamesById(project.user_ids);` `delete project.user_ids;` `}` `res.status(result.statusCode).json(result);` `}` 在前面的代码中,我们发送了与项目关联用户的`**user_id**`以及用户名。因此,我们在用户工具中创建了一个方法,从`**user_id**`获取用户名,如下所示: `public static async **getUsernamesById**(user_ids: string[]) {` `const userService = new UsersService();` `// 查询数据库以检查所有 user_ids 是否有效` `const queryResult = await userService.findByIds(user_ids);` `if (queryResult.statusCode === 200) {` `const users = queryResult.data;` `const usernames = users.map((i) => {` `return {` `'username': i.username,` `'user_id': i.user_id` `};` `});` `return usernames;` `}` `return [];` `}` `ThegetAllHandler**`` method uses the ProjectsServiceclass to retrieve all projects from the database based on the query parameters in the request. The resulting data is then sent back to the client with an appropriate HTTP status code and formatted as JSON. The routes class inproject_routes.tscan be updated to make a call to the new handler for GET request as: app.route(this.baseEndPoint) .all(authorize) .post(validate(validProjectInput), controller.addHandler) .get(controller.getAllHandler); By employing this approach, we establish aGETroute that fetches all projects stored in the database, effectively functioning as a REST API endpoint for retrieving project data. **REST API**GetAll**Projects** **Request** URL : http://127.0.0.1:3000/api/projects Method: GET Query Params: {} **Response** { "statusCode": 200, "status": "success", "data": [ { "project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca", "name": "Project Management", "description": "This Project is about for Management", "start_time": "2023-09-24T18:30:00.000Z", "end_time": "2023-12-14T18:30:00.000Z", "created_at": "2023-09-23T17:23:36.061Z", "updated_at": "2023-09-23T17:23:36.061Z", "users": [ { "username": "pms-admin", "user_id": "b930d02c-43af-4875-b7e9-546c9f4c23dd" }, { "username": "pms-admin1", "user_id": "611b346e-be39-4a7e-96d1-e7421193bd5a" }, { "username": "yamini", "user_id": "d166945a-f85d-485c-bdac-0c8056b3188a" } ] } ] } # Search Project by Name To search for a project by name, you can utilize the same API endpoint as the one used for retrieving all projects. However, in this case, you will include query parameters to specify the search criteria. **Request** URL : http://127.0.0.1:3000/api/projects?name=Project Management Method: GET Query Params: {name: Project Management } **Response** { "statusCode": 200, "status": "success", "data": [ { "project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca", "name": "Project Management", "description": "This Project is about for Management", "start_time": "2023-09-24T18:30:00.000Z", "end_time": "2023-12-14T18:30:00.000Z", "created_at": "2023-09-23T17:23:36.061Z", "updated_at": "2023-09-23T17:23:36.061Z", "users": [ { "username": "pms-admin", "user_id": "b930d02c-43af-4875-b7e9-546c9f4c23dd" }, { "username": "pms-admin1", "user_id": "611b346e-be39-4a7e-96d1-e7421193bd5a" }, { "username": "yamini", "user_id": "d166945a-f85d-485c-bdac-0c8056b3188a" } ] } ] } # GetOne Project The “*GetOne Project*” endpoint plays a pivotal role in project management systems, allowing users to access in-depth information about a specific project without the requirement to fetch the complete project list. This functionality is indispensable for providing precise details regarding the attributes and permissions linked to each project. To implement the “*GetOne Project*” API, modify thegetOneHandlercode in the project controller as follows: public async getOneHandler(req: Request, res: Response): Promise { if (!hasPermission(req.user.rights, 'get_details_project')) { res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' }); } const service = new ProjectsService(); const result = await service.findOne(req.params.project_id); result.data['users'] = await UsersUtil.getUsernamesById(result.data.user_ids); delete result.data.user_ids; res.status(result.statusCode).json(result); } The ``**getOneHandler**`** function serves as the bridge between the incoming client request, the service layer that interacts with the database, and the outgoing HTTP response. It retrieves a single project’s details from the database based on the provided project ID and sends the project information back to the client. This method is called from the routes file by creating a new route for it as follows: `app.route(this.baseEndPoint + '/**:project_id'**)` `.all(authorize)` `.get(controller.getOneHandler);` Here, /:project_id will be a request parameter meant to capture the ID of the project that the user wants to retrieve. Parameters passed to APIs can be read using **request.params**, as seen in the previous example while reading **project_id**: const project_id = req.params.project_id; REST API GetOne Project Request URL : http://127.0.0.1:3000/api/projects/ Method: GET Response { "statusCode": 200, "status": "success", "data": { "project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca", "name": "Project Management", "description": "This Project is about for Management", "start_time": "2023-09-24T18:30:00.000Z", "end_time": "2023-12-24T18:30:00.000Z", "created_at": "2023-09-23T17:23:36.061Z", "updated_at": "2023-09-24T17:18:55.827Z", "users": [ { "username": "pms-admin", "user_id": "b930d02c-43af-4875-b7e9-546c9f4c23dd" }, { "username": "pms-admin1", "user_id": "611b346e-be39-4a7e-96d1-e7421193bd5a" }, { "username": "yamini", "user_id": "d166945a-f85d-485c-bdac-0c8056b3188a" } ] } } Providing a valid project ID will yield a successful response, while inputting an ID that doesn’t correspond to an existing database entry will result in a **404 error**, signifying that the requested entity was not found. { "statusCode": 404, "status": "error", "message": "Not Found" } # Update Project The process of updating a project details involves making changes to the existing data of a particular project that is stored in the database. Through this process, you can modify attributes such as the project’s name, description, and associated users. Updating a project is crucial for maintaining the accuracy and currency of project information, especially when there are alterations in project permissions that need to be reflected in the database. To implement the **"Update Project"** API, make the following changes in the **updateHandler** code within the project controller: public async **updateHandler**(req: Request, res: Response): Promise<void> { if (!hasPermission(req.user.rights, 'edit_project')) { res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' }); return; } const project = req.body; const service = new ProjectsService(); const result = await service.update(req.params.id, project); res.status(result.statusCode).json(result); } The **updateHandler** function handles requests to update a task. It begins by checking if the user has the necessary permission, and if so, it extracts the updated project data, initializes the project service, performs the update operation in the database, and responds to the client with the appropriate status code and result data. This method is called from the routes file by creating a new route for it as follows: app.route(this.baseEndPoint + '/:id') .all(authorize) .get(controller.getOneHandler) **.put(validate(validProjectInput), controller.updateHandler);** Here, **/:id**` ` will be a request parameter meant to capture the ID of the project that the user wants to retrieve, and it also validates data before updating in the database. **REST API Update Project** **Request** `URL : http://127.0.0.1:3000/api/projects/` `Method: PUT` `body :` `{` `"name":"Project Management",` `"description":"This Project is about for Management",` `"user_ids":["b930d02c-43af-4875-b7e9-546c9f4c23dd","611b346e-be39-4a7e-96d1-e7421193bd5a","d166945a-f85d-485c-bdac-0c8056b3188a"],` `"start_time":"2023-09-25 00:00:00",` `"end_time":"2023-12-25 00:00:00"` `}` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": {` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"name": "Project Management",` `"description": "This Project is about for Management",` `"user_ids": [` `"b930d02c-43af-4875-b7e9-546c9f4c23dd",` `"611b346e-be39-4a7e-96d1-e7421193bd5a",` `"d166945a-f85d-485c-bdac-0c8056b3188a"` `],` `"start_time": "2023-09-24T18:30:00.000Z",` `"end_time": "2023-12-24T18:30:00.000Z",` `"created_at": "2023-09-23T17:23:36.061Z",` `"updated_at": "2023-09-24T17:18:55.827Z"` `}` `}` Providing a valid project ID will yield a successful response, while inputting an ID that doesn’t correspond to an existing database entry will result in a `**404 error**`, signifying that the requested entity was not found. `{` `"statusCode": 404,` `"status": "error",` `"message": "Not Found"` `}` # Delete Project The "`**delete**`” functionality for projects in a REST API involves the removal of a specific project from the database. This process is managed through an endpoint dedicated to project deletion. When a request is made to this endpoint, it triggers a function that handles the deletion process. The incoming request typically contains the unique identifier (`**project_id**`) of the project that needs to be deleted. To implement the Delete Project API, make the following changes in the `**deleteHandler**` code in the project controller: `public async **deleteHandler**(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'delete_project')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `const service = new ProjectsService();` `const result = await service.delete(req.params.id);` `res.status(result.statusCode).json(result);` `}` The deleteHandlerprocesses the request by utilizing a service that interacts with the database. This service is responsible for executing the deletion operation. If the requested project exists in the database and the deletion is successful, the function responds with a success message and an appropriate status code, such as `**200 OK**`. If the role does not exist, the function returns an error response with a status code of `**404 Not Found**`, indicating that the project was not located in the database. This method is called from the routes file by creating a new route for it as follows: `app.route(this.baseEndPoint + '/:id')` `.all(authorize)` `.get(controller.getOneHandler)` `.put(validate(validProjectInput), controller.updateHandler)` `**.delete(controller.deleteHandler);**` Here,/:id will be a request parameter meant to capture the ID of the project that the user wants to delete. REST API Delete Project Request URL : http://127.0.0.1:3000/api/projects/ Method: DELETE Response { "statusCode": 200, "status": "success" } In the case of an already deleted or not exist in the database: { "statusCode": 404, "status": "error", "message": "Not Found" } # Project Util Following the development of the foundational base API for Project, several functions have been crafted within the Project Util class. These functions serve as valuable helpers and are intended for utilization in various other modules. Now, let’s proceed to establish the **ProjectUtil** class within the **projects_controller.ts** file. The provided code snippet outlines the structure of this class: export class **ProjectsUtil** { public static async **checkValidProjectIds**(project_ids: string[]) { const projectService = new ProjectsService(); // Query the database to check if all project_ids are valid const projects = await projectService.findByIds(project_ids); // Check if all project_ids are found in the database return projects.data.length === project_ids.length; } } In the preceding code, the **`checkValidProjectIds**** function checks whether the given projects_idsare valid or not in the database. In summary, a well-designed RESTful API project module enables organizations to efficiently manage their projects, collaborate effectively, and integrate project data into their applications and systems while adhering to best practices in security, validation, and documentation. # Task Management To manage tasks effectively, individuals and teams often use task management tools and software. This module provides features for creating, assigning, prioritizing, and tracking tasks. Tasks are the building blocks of project management. They help project managers and teams break down complex projects into manageable parts, allocate resources efficiently, and ensure that work progresses according to the project plan. Let’s start creating a task service first to manage tasks. # Task Service Task Service is to facilitate the creation, updation, deletion, and retrieval of task details as required. Let’s create atasks_services.tsfile in the tasks directory with the following code: import { Repository } from 'typeorm'; import { BaseService } from '../../utils/base_service'; import { DatabaseUtil } from '../../utils/db'; import { Tasks } from './tasks_entity'; export class TasksService extends BaseService { constructor() { let taskRepository: Repository | null = null; taskRepository = new DatabaseUtil().getRepository(Tasks); super(taskRepository); } } The ```js**TasksService**``类从基类继承,允许它与数据库存储库交互,以管理任务。构造函数初始化任务数据库存储库,便于应用程序中与任务相关的功能进行数据库操作。 我们将按以下方式开发项目的 REST API: * 添加任务 * 获取所有任务 * 获取一个任务 * 更新任务 * 删除任务 # Input Validation 任务输入验证是确保作为软件应用程序中任务输入提供的数据符合预定义标准和约束的过程。 现在,让我们在tasks_routes.ts文件中添加validTaskInput,如下所示: import { Express } from 'express'; import { TaskController } from './tasks_controller'; import { body } from 'express-validator'; import { checkValidDate } from '../../utils/common'; import { validate } from '../../utils/validator'; import { authorize } from '../../utils/auth_util'; `````const validTaskInput = [ body('name').trim().notEmpty().withMessage('It should be required'), body('project_id').trim().notEmpty().withMessage('It should be required'), body('user_id').trim().notEmpty().withMessage('It should be required'), body('estimated_start_time').trim().notEmpty().withMessage('It should be required'), body('estimated_end_time').trim().notEmpty().withMessage('It should be required'), body('estimated_start_time').custom((value) => { if (!checkValidDate(value)) { throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss'); } const startTime = new Date(value); const currentTime = new Date(); if (startTime <= currentTime) { throw new Error('Start time must be greater than the current time'); } return true; }), body('estimated_end_time').custom((value, { req }) => { if (!checkValidDate(value)) { throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss'); } const startTime = new Date(req.body.start_time); const endTime = new Date(value); if (endTime <= startTime) { throw new Error('End time must be greater than the start time'); } return true; }) ]; Here are the details of the key parts of the preceding code: * body('name').trim().notEmpty().withMessage('It should be required'): This validates the name field in the request body. .trim()removes any leading or trailing whitespace from the input;.notEmpty()checks that the field is not empty;.withMessage('It should be required')provides a custom error message if the validation fails, indicating that the ‘name’ field is required. * body('project_id').trim().notEmpty().withMessage('It should be required'): This validates the name field in the request body. .trim() removes any leading or trailing whitespace from the input; .notEmpty()checks that the field is not empty;.withMessage('It should be required')provides a custom error message if the validation fails, indicating that the'project_id' field is required. * body('user_id').trim().notEmpty().withMessage('It should be required'): This validates the name field in the request body. .trim()removes any leading or trailing whitespace from the input;.notEmpty()checks that the field is not empty;.withMessage('It should be required') provides a custom error message if the validation fails, indicating that the 'user_id' field is required. * body('estimated_start_time').trim().notEmpty().withMessage('It should be required'): This validates the name field in the request body. .trim()removes any leading or trailing whitespace from the input;.notEmpty()checks that the field is not empty;.withMessage('It should be required') provides a custom error message if the validation fails, indicating that the 'estimated_start_time' field is required. * body('estimated_end_time').trim().notEmpty().withMessage('It should be required'): This validates the name field in the request body. .trim()removes any leading or trailing whitespace from the input;.notEmpty()checks that the field is not empty;.withMessage('It should be required') provides a custom error message if the validation fails, indicating that the ''estimated_end_time'' field is required. * body('estimated_start_time').custom((value) => { /* … / }): This validates the start_time field in the request body using a custom validation function. The custom validation function checks if the value (the 'start_time' value) is in a valid date format (YYYY-MM-DD HH:mm:ss). It then compares the 'estimated_start_time' with the current time, ensuring that the 'estimated_start_time' is greater than the current time. If any of these checks fail, it throws an error with a custom message. * body('estimated_end_time').custom((value, { req }) => { // }): This validates the estimated_start_time field in the request body using another custom validation function. Similar to the previous custom function, it checks if the value (the 'estimated_end_time' value) is in a valid date format (YYYY-MM-DD HH:mm:ss). It also accesses the req.body. to compare the 'estimated_end_time' with the 'estimated_start_time' to ensure that 'estimated_end_time' is greater than 'estimated_start_time'. If any of these checks fail, it throws an error with a custom message. In this manner, we can implement fundamental validation for tasks. # Add Task When employing the REST API to initiate the addition of a task, the customary procedure involves providing essential information within the request body. This information typically encompasses details such as the task’s name, description, and any related attributes. The dedicated API endpoint, tailored for this specific purpose, is designed to receive and meticulously validate this data, ensuring it aligns with predefined criteria. Subsequently, this validation process culminates in the creation of a fresh task, founded upon the information furnished in the request. Previously, we established the initial structure of the tasks_controller.tsclass as a skeletal outline. Now, let’s proceed to enhance its functionality by extending it from theBaseControllerand leveraging the capabilities of theBaseServiceto carry out database operations. Following is the code for this implementation: import { Response, Request } from 'express'; import { hasPermission } from '../../utils/auth_util'; import { BaseController } from '../../utils/base_controller'; import { TasksService } from './tasks_service'; import { UsersUtil } from '../users/users_controller'; import { ProjectsUtil } from '../projects/projects_controller'; export class TaskController extends BaseController { / Handles the addition of a new user. * @param {object} req - The request object. * @param {object} res - The response object. / public async addHandler(req: Request, res: Response): Promise { if (!hasPermission(req.user.rights, 'add_task')) { res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' }); return; } try { // Create an instance of the ProjectService const service = new TasksService(); // Extract task data from the request body const task = req.body; //check if the provided project_id is valid const isValidProject = await ProjectsUtil. checkValidProjectIds([task.project_id]); if (!isValidProject) { // If user_ids are invalid, send an error response res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid project_id' }); return; } // Check if the provided user_id is valid const isValidUser = await UsersUtil.checkValidUserIds([task.user_id]); if (!isValidUser) { // If user_ids are invalid, send an error response res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid user_id' }); return; } // If user_ids are valid, create the user const createdTask = await service.create(task); res.status(201).json(createdTask); } catch (error) { // Handle errors and send an appropriate response ``console.error(Error while addUser => \({error.message}`);`` `res.status(500).json({ statusCode: 500, status: 'error', message: 'Internal server error' });` `}` `}` ```js` `public async getAllHandler(req: Request, res: Response) {}` `public async getOneHandler(req: Request, res: Response) {}` `public async updateHandler(req: Request, res: Response) {}` `public async deleteHandler(req: Request, res: Response) {}` `}` The `**addHandler**` method retrieves the data provided within the incoming request body. Subsequently, it forwards this data to the Base service by calling the create method, responsible for integrating this information into the database. Additionally, we are extending this process to include the assignment of the user and project to the task, effectively associating the user and project with the task during its creation. Now, let’s call `**addHandler**` in the task routes with change in the `**tasks_router.ts**` file as follows: `export class TaskRoutes {` `private baseEndPoint = '/api/projects';` `constructor(app: Express) {` `const controller = new ProjectController();` `app.route(this.baseEndPoint)` `.all(authorize)` `.post(validate(validTaskInput), controller.addHandler);` `}` `}` We have established routes for adding tasks and incorporated middleware to validate requests before inserting data into the database. **REST API Add Project** **Request** `URL : http://127.0.0.1:3000/api/tasks` `Method: POST` `body :` `{` `"name":"Setup Database",` `"description":"create one postgres database and setup database for project` `management project",` `"project_id":"c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"user_id":"d166945a-f85d-485c-bdac-0c8056b3188a",` `"estimated_start_time":"2023-10-01 00:00:00",` `"estimated_end_time":"2023-10-02 00:00:00"` `}` **Response** `{` `"statusCode": 201,` `"status": "success",` `"data": {` `"task_id": "74f61799-7046-47d9-8f04-897f07b4e178",` `"name": "Setup Database",` `"description": "create one postgres database and setup database for project management project",` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"user_id": "d166945a-f85d-485c-bdac-0c8056b3188a",` `"estimated_start_time": "2023-10-01 00:00:00",` `"estimated_end_time": "2023-10-02 00:00:00",` `"actual_start_time": null,` `"actual_end_time": null,` `"priority": "Low",` `"status": "Not-Started",` `"supported_files": [],` `"created_at": "2023-09-30T12:17:51.138Z",` `"updated_at": "2023-09-30T12:17:51.138Z"` `}` `}` In the case of a unique task name, trying again with the same request gives an error as `**409**` conflict code: `{` `"statusCode": 409,` `"status": "error",` `"message": "Key (name)=(Setup Database) already exists."` `}` In another case, if you change in rights as “`**rights**`”:”`**no_rights**`”, it gives an error for a Bad Request with a `**400**` status code as follows: `{` `"statusCode": 400,` `"status": "error",` `"errors": [` `{` `"rights": "Invalid permission"` `}` `]` `}` In the database for the task entity, we added the default value for priority as “`**Low**`” and status as “`**Not-Started**`”, so that it takes automatically while creating the task. # GetAll Task After successfully adding a task to the database, the next step involves retrieving the newly inserted task from the database. To achieve this, update the `**getAllHandler**` method in the `**tasks_controller.ts**` file with the following code: `public async getAllHandler(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'get_all_tasks')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `const service = new TasksService();` `const result = await service.findAll(req.query);` `res.status(result.statusCode).json(result);` `}` A Task is associated with both a project and a user. In this API, we must also display the details of the project and user. Therefore, the standard `**findAll**` method from the base service does not provide the functionality we require. To achieve this, we need to override the `**findAll**` method in the `**tasks_service.ts**` file with the following code: `export class TasksService extends BaseService<Tasks> {` `private taskRepository: Repository<Tasks> | null = null;` `constructor() {` `let taskRepository: Repository<Tasks> | null = null;` `taskRepository = new DatabaseUtil().getRepository(Tasks);` `super(taskRepository);` `this.taskRepository = taskRepository;` `}` `// Override the method from the base service class` `override async findAll(queryParams: object): Promise<ApiResponse<Tasks[]>> {` `const queryBuilder = await this.taskRepository` `.createQueryBuilder('task')` `.leftJoin('task.project_id', 'project')` `.leftJoin('task.user_id', 'user')` `.addSelect([` `'task.*',` `'task.project_id as project',` `'project.project_id',` `'project.name',` `'user.user_id',` `'user.username',` `'user.email',` `]);` `// Build the WHERE clause conditionally based on the search parameters` `if (queryParams['username']) {` `queryBuilder.andWhere('user.username ILIKE :userName', {` `userName:` `` `%\){queryParams['username']}%`` }); } if (queryParams['projectname']) { queryBuilder.andWhere('project.name ILIKE :projectName', { projectName: ``%${queryParams['projectname']}%`` }); } if (queryParams['project_id']) { queryBuilder.andWhere('task.project_id = :projectId', { projectId: queryParams['project_id'] }); } const data = await queryBuilder.getMany(); data.forEach((item) => { item['projectDetails'] = item.project_id; item['userDetails'] = item.user_id; delete item.project_id; delete item.user_id; }); return { statusCode: 200, status: 'success', data: data }; } } We created a query builder using TypeORM to build an SQL query for retrieving tasks with specific details. We use joins to fetch related data from the project and user tables. We specify the columns we want to select in the query using theaddSelectmethod. We conditionally addWHEREclauses to the query based on the values provided in thequeryParamsobject. For example, if the username orprojectNameis provided in thequeryParams, we add a condition to filter the results accordingly. Overall, this custom findAllmethod enhances the base service’s functionality to retrieve tasks with associated project and user details, while also allowing for conditional filtering based on specific criteria. For a better understanding of which query is running, we can enable logging in the config of the database connection, in theconnectDatabasefunction indb.tsfile: public async connectDatabase() { try { if (DatabaseUtil.connection) { return DatabaseUtil.connection; } else { const db_config = this.server_config.db_config; const AppSource = new DataSource({ type: 'postgres', host: db_config.host, port: db_config.port, username: db_config.username, password: db_config.password, database: db_config.dbname, entities: [Roles, Users, Projects, Tasks, Comments], synchronize: true, logging: true, poolSize: 10 }); await AppSource.initialize(); DatabaseUtil.connection = AppSource; console.log('Connected to the database'); return DatabaseUtil.connection; } } catch (error) { console.error('Error connecting to the database:', error); } } The ```**getAllHandlermethod uses the `**TasksService**` class to retrieve all projects from the database based on the query parameters in the request. The resulting data is then sent back to the client with an appropriate HTTP status code and formatted as JSON. This controller method call in routes with a change in `**tasks_routes.ts**` as follows: `app.route(this.baseEndPoint)` `.all(authorize)` `.post(validate(validProjectInput), controller.addHandler)` `**.get(controller.getAllHandler);**` By employing this approach, we establish a `**GET**` route that fetches all tasks stored in the database, effectively functioning as a REST API endpoint for retrieving task data. When above API is triggered in terminal, you can see query as follows : ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.5.jpg) **Figure 6.5:** Enable Query Logging Now, copy this query and run in `**pgAdmin**` or `**DBeaver**` as follows: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.6.jpg) **Figure 6.6:** PgAdmin Run Query **REST API GetAll Tasks** **Request** `URL : http://127.0.0.1:3000/api/tasks` `Method: GET` `Query Params: {}` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": [` `{` `"task_id": "74f61799-7046-47d9-8f04-897f07b4e178",` `"name": "Setup Database",` `"description": "create one postgres database and setup database for project management project",` `"estimated_start_time": "2023-09-30T18:30:00.000Z",` `"estimated_end_time": "2023-10-01T18:30:00.000Z",` `"actual_start_time": null,` `"actual_end_time": null,` `"priority": "Low",` `"status": "Not-Started",` `"supported_files": [],` `"created_at": "2023-09-30T12:17:51.138Z",` `"updated_at": "2023-09-30T12:17:51.138Z",` `"projectDetails": {` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"name": "Project Management"` `},` `"userDetails": {` `"user_id": "d166945a-f85d-485c-bdac-0c8056b3188a",` `"username": "yamini",` `"email": "yamipanchal1993@gmail.com"` `}` `}` `]` `}` # Search Task To search for a task by `**project_id**`, `**projectname**`, or `**username**`, you can utilize the same API endpoint as the one used for retrieving all tasks. However, in this case, you will include query parameters to specify the search criteria. **Request** `URL : http://127.0.0.1:3000/api/projects?projectname=project&` `username=yamini` `Method: GET` `Query Params: {` `"projectname": "project",` `"username":"yamini"` `}` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": [` `{` `"task_id": "74f61799-7046-47d9-8f04-897f07b4e178",` `"name": "Setup Database",` `"description": "create one postgres database and setup database for project management project",` `"estimated_start_time": "2023-09-30T18:30:00.000Z",` `"estimated_end_time": "2023-10-01T18:30:00.000Z",` `"actual_start_time": null,` `"actual_end_time": null,` `"priority": "Low",` `"status": "Not-Started",` `"supported_files": [],` `"created_at": "2023-09-30T12:17:51.138Z",` `"updated_at": "2023-09-30T12:17:51.138Z",` `"projectDetails": {` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"name": "Project Management"` `},` `"userDetails": {` `"user_id": "d166945a-f85d-485c-bdac-0c8056b3188a",` `"username": "yamini",` `"email": "yamipanchal1993@gmail.com"` `}` `}` `]` `}` # GetOne Task The “*Retrieve Individual Task*” endpoint holds a significant role within project management systems. It grants users access to comprehensive task details without necessitating the retrieval of the entire task catalog. This capability is crucial for delivering accurate insights into the specific attributes and permissions associated with each task. To implement the `**"GetOne Task"**` API, modify the `**getOneHandler**` code in the task controller as follows: `public async getOneHandler(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'get_details_task')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `}` `const service = new TaskService();` `const result = await service.findOne(req.params.id);` `res.status(result.statusCode).json(result);` `}` ThegetOneHandler**``**** function serves as the bridge between the incoming client request, the service layer that interacts with the database, and the outgoing HTTP response. It retrieves a single task’s details from the database based on the provided task ID and sends the task information back to the client. Similarly, override **findAll** method in service needs to override the **findOne** method in the **tasks_service.ts** file with the following code: override async findOne(id: string): Promise<ApiResponse<Tasks> | undefined> { try { // Build the WHERE condition based on the primary key const where = {}; const primaryKey: string = this.taskRepository.metadata.primaryColumns[0].databaseName; where[primaryKey] = id; // Use the repository to find the entity based on the provided ID const data = await this.taskRepository .createQueryBuilder('task') .leftJoin('task.project_id', 'project') .leftJoin('task.user_id', 'user') .addSelect([ 'task.*', 'task.project_id as project', 'project.project_id', 'project.name', 'user.user_id', 'user.username', 'user.email', ]) .where(where) .getOne(); if (data) { data['projectDetails'] = data.project_id; data['userDetails'] = data.user_id; delete data.project_id; delete data.user_id; return { statusCode: 200, status: 'success', data: data }; } else { return { statusCode: 404, status: 'error', message: 'Not Found' }; } } catch (error) { return { statusCode: 500, status: 'error', message: error.message }; } } In this context, we have introduced a **"where"** condition to narrow down the data retrieval to a specific task. This condition is applied within the **getAOne** method from the controller, which is invoked from the routes file by creating a new route as follows: app.route(this.baseEndPoint + '/:id') .all(authorize) **.get(controller.getOneHandler);** Here, /:id will be a request parameter meant to capture the ID of the task that the user wants to retrieve. Once you trigger an API query, it will be logged in the terminal as shown below, and then you can copy it, change \(1 to actual parameters, and paste it into `**pgAdmin**` or `**DBeaver**` as follows: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.7.jpg) **Figure 6.7:** Get One Task Query ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.8.jpg) **Figure 6.8:** PgAdmin GetOne Task Query Output **REST API GetOne Task** **Request** `URL : http://127.0.0.1:3000/api/tasks/74f61799-7046-47d9-8f04-897f07b4e178` `Method: GET` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": {` `"task_id": "74f61799-7046-47d9-8f04-897f07b4e178",` `"name": "Setup Database",` `"description": "create one postgres database and setup database for project management project",` `"estimated_start_time": "2023-09-30T18:30:00.000Z",` `"estimated_end_time": "2023-10-01T18:30:00.000Z",` `"actual_start_time": null,` `"actual_end_time": null,` `"priority": "Low",` `"status": "Not-Started",` `"supported_files": [],` `"created_at": "2023-09-30T12:17:51.138Z",` `"updated_at": "2023-09-30T12:17:51.138Z",` `"projectDetails": {` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"name": "Project Management"` `},` `"userDetails": {` `"user_id": "d166945a-f85d-485c-bdac-0c8056b3188a",` `"username": "yamini",` `"email": "yamipanchal1993@gmail.com"` `}` `}` `}` Providing a valid task ID will yield a successful response, while inputting an ID that doesn’t correspond to an existing database entry will result in a 404 error, signifying that the requested entity was not found. `{` `"statusCode": 404,` `"status": "error",` `"message": "Not Found"` `}` # Update Task The process of updating a task detail involves making changes to the existing data of a particular task that is stored in the database. Through this process, you can modify attributes such as the task’s name, description, and associated users or projects. Updating a task is crucial for maintaining the accuracy and currency of task information, especially when there are alterations in task permissions that need to be reflected in the database. To implement the `**"Update Task"**` API, make the following changes in the `**updateHandler**` code within the project controller: `public async **updateHandler**(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'edit_task')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `const task = req.body;` `const service = new TasksService();` `const result = await service.update(req.params.id, task);` `res.status(result.statusCode).json(result);` `}` The **`**`**updateHandler**`**`** function handles requests to update a task. It begins by checking if the user has the necessary permission, and if so, it extracts the updated task data, initializes the task service, performs the update operation in the database, and responds to the client with the appropriate status code and result data. Here, we have added some validation in `**updateTaskInput**`. This method is called from the routes file by creating a new route for it as follows: `const **updateTaskInput** = [` `body('estimated_start_time').custom((value) => {` `if (value && !checkValidDate(value)) {` `throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss');` `}` `const startTime = new Date(value);` `const currentTime = new Date();` `if (startTime <= currentTime) {` `throw new Error('Start time must be greater than the current time');` `}` `return true;` `}),` `body('estimated_end_time').custom((value, { req }) => {` `if (value && !checkValidDate(value)) {` `throw new Error('Invalid date format YYYY-MM-DD HH:mm:ss');` `}` `const startTime = new Date(req.body.start_time);` `const endTime = new Date(value);` `if (endTime <= startTime) {` `throw new Error('End time must be greater than the start time');` `}` `return true;` `})` `];` ```js `app.route(this.baseEndPoint + '/:id')` `.all(authorize)` `.get(controller.getOneHandler)` `**.put(validate(updateTaskInput), controller.updateHandler);**` Here, ``**/:id**` ` will be a request parameter meant to capture the ID of the task that the user wants to retrieve, and it also validates data before updating in the database. **REST API Update Task** **Request** `URL : http://127.0.0.1:3000/api/tasks/74f61799-7046-47d9-8f04-897f07b4e178` `Method: PUT` `body :` `{` `"name":"Create Microservices",` `"description":"Add microservice to store data on cloud",` `"project_id":"c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"user_id":"d166945a-f85d-485c-bdac-0c8056b3188a",` `"status":"In-Progress"` `}` **Response** `{` `"statusCode": 200,` `"status": "success",` `"data": {` `"task_id": "74f61799-7046-47d9-8f04-897f07b4e178",` `"name": "Create Microservices",` `"description": "Add microservice to store data on cloud",` `"estimated_start_time": "2023-09-30T18:30:00.000Z",` `"estimated_end_time": "2023-10-01T18:30:00.000Z",` `"actual_start_time": null,` `"actual_end_time": null,` `"priority": "Low",` `"status": "In-Progress",` `"supported_files": [],` `"created_at": "2023-09-30T12:17:51.138Z",` `"updated_at": "2023-10-07T15:57:21.912Z",` `"project_id": "c2e9b17b-0af2-453b-b0c9-43ea2d304dca",` `"user_id": "d166945a-f85d-485c-bdac-0c8056b3188a"` `}` `}` Providing a valid task ID will yield a successful response, while inputting an ID that doesn’t correspond to an existing database entry will result in a `**404**` error, signifying that the requested entity was not found. `{` `"statusCode": 404,` `"status": "error",` `"message": "Not Found"` `}` # Delete Task The "`**delete**`” functionality for tasks in a REST API involves the removal of a specific task from the database. This process is managed through an endpoint dedicated to task deletion. When a request is made to this endpoint, it triggers a function that handles the deletion process. The incoming request typically contains the unique identifier (`**task_id**`) of the task that needs to be deleted. To implement the `**Delete**` Task API, make the following changes in the `**deleteHandler**` code in the task controller as follows: `public async **deleteHandler**(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req.user.rights, 'delete_task')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `const service = new TasksService();` `const result = await service.delete(req.params.id);` `res.status(result.statusCode).json(result);` `}` The ``**`deleteHandler`**`` processes the request by utilizing a service that interacts with the database. This service is responsible for executing the deletion operation. If the requested project exists in the database and the deletion is successful, the function responds with a success message and an appropriate status code, such as `**200 OK**`. If the role does not exist, the function returns an error response with a status code of `**404 Not Found**`, indicating that the project was not located in the database. This method is called from the routes file by creating a new route for it as follows: `app.route(this.baseEndPoint + '/:id')` `.all(authorize)` `.get(controller.getOneHandler)` `.put(validate(updateTaskInput), controller.updateHandler)` `**.delete(controller.deleteHandler);**` Here, ``**/:id**` ` will be a request parameter meant to capture the ID of the task that the user wants to delete. **REST API Delete Task** **Request** `URL : http://127.0.0.1:3000/api/tasks/74f61799-7046-47d9-8f04-897f07b4e178` `Method: DELETE` **Response** `{` `"statusCode": 200,` `"status": "success"` `}` In the case of an already deleted or not exist in the database: `{` `"statusCode": 404,` `"status": "error",` `"message": "Not Found"` `}` We’ve established various modules such as `**Role**`, `**User**`, `**Project**`, and `**Task**`. Similarly, you can create a Comment module and build APIs for handling comments. Within this module, comments are associated with a `**task_id**`. Users can add comments related to tasks, update their own comments, and delete their own comments. This aspect of the project is meant for your personal learning and experimentation. # Upload Supported Files In both tasks and comments, the ability to attach files is provided through the file module. You can create a file module that includes CRUD (Create, Read, Update, Delete) operations, following a structure similar to that of the project and task modules. The file module should define database fields such as `**file_id**`, `**file_url**`, `**file_name**`, `**created_by**`, `**created_at**`, `**updated_at**`, and more. Additionally, within the supported files array, you can add or remove `**file_id**` based on the corresponding action taken. For reference, the `**file_entity.ts**` is as follows: `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, JoinColumn, ManyToOne } from 'typeorm';` `import { Users } from '../users/users_entity';` `import { Tasks } from '../tasks/tasks_entity';` `@Entity()` `export class Files {` `@PrimaryGeneratedColumn('uuid')` `file_id: string;` `@Column({ length: 30, nullable: false, unique: true })` `file_name: string;` `@Column({ length: 30 })` `mime_type: string;` `@Column()` `@ManyToOne(() => Users, (userData) => userData.user_id)` `@JoinColumn({ name: 'user_id' })` `created_by: string;` `@Column()` `@ManyToOne(() => Tasks, (taskData) => taskData.task_id)` `@JoinColumn({ name: 'task_id' })` `task_id: string;` `@Column()` `@CreateDateColumn()` `created_at: Date;` `@Column()` `@UpdateDateColumn()` `updated_at: Date;` `}` To send files from your website to your Node.js server, we will use `**Multer**`. It’s widely used for its ability to easily handle file uploads. Once a file is uploaded, `**Multer**` lets you save it in a specific folder on your server. Not only does it help with storing files, but it also helps you keep track of their locations, making it easier to access and manage these files later. `**Multer**` integrates with Node.js smoothly and offers a straightforward way to upload files. The following steps will help to set up `**Multer**` for our project: * **Install Multer** First, make sure that you have Multer installed in your `**Node.js**` project. If you haven’t already, you can install it using npm: `\) npm install multer uuid --save $ npm install @types/multer @types/uuid --save-dev * Create one folder in root directory asattachedFileswhere all files will be uploaded. Inserver_config.json, add one parameter for path of uploaded files absolute path and change in IServerConfiginterface as follows: export interface IServerConfig { port: number; db_config: { 'db': string; 'username': string; 'password': string; 'host': string; 'port': number; 'dbname': string; }; email_config: { 'from': string; 'user': string; 'password': string; }; front_app_url: string; default_user?: { email: string; password: string; }; attached_files_path?: string; } * **Develop Multer Middleware** In the utils directory, create a file namedmulter.tsfor a middleware that uploads files to a specific folder with the following code: import multer from 'multer'; import { Request } from 'express'; import { IServerConfig } from './config'; import * as config from '../../server_config.json'; // Define the options for Multer export const multerConfig = { storage: multer.diskStorage({ destination: (req, file, cb) => { // Set the destination folder where files will be saved const server_config: IServerConfig = config; cb(null, server_config.attached_files_path); // Change 'attachedFiles' to your desired folder name }, filename: (req, file, cb) => { // Generate a unique filename for the uploaded file ``const uniqueFileName =\({Date.now()}-\){file. originalname};`` cb(null, uniqueFileName); } }), fileFilter: (req, file, cb) => { // Define the allowed file types (MIME types) here. const allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf']; // Check if the uploaded file's MIME type is in the allowed list. if (allowedMimeTypes.includes(file.mimetype)) { cb(null, true); // Accept the file } else { cb(new Error('Invalid file type. Only PDF, JPEG, and PNG files are allowed.'), false); // Reject the file } } }; const upload = multer(multerConfig); // Export the Multer middleware export const fileUploadMiddleware = upload.single('file'); export const uploadFile = (req: Request) => { if (!req.file) { throw new Error('No file provided'); } // Here you can perform additional processing and file storage logic // For example, save the file to a storage service or local directory const fileData = req.file; return fileData; }; Here in the code,attachedFilesis the directory name where uploaded files are stored. You can change the name as you want. We allow only PDF and image file types, which are also changeable. * **Handle File Uploads in Your Route** In your file module creation API, use the Multer middleware you created to handle file uploads. Here’s an example of how to use it in an Express route: import { Express } from 'express'; import { FileController } from './files_controller'; import { fileUploadMiddleware } from '../../utils/multer'; import { authorize } from '../../utils/auth_util'; export class FileRoutes { private baseEndPoint = '/api/files'; constructor(app: Express) { const controller = new FileController(); app.route(this.baseEndPoint) .all(authorize) .post(fileUploadMiddleware, controller.addHandler); } } In the controller, the following code is used to storefileDatain the database, which is called from the router: import { BaseController } from '../../utils/base_controller'; import { uploadFile } from '../../utils/multer'; import { Request, Response } from 'express'; import { FilesService } from './files_service'; import { Files } from './files_entity'; export class FileController extends BaseController { /** Handles the addition of a new file. * @param {object} req - The request object. * @param {object} res - The response object. */ public async addHandler(req: Request, res: Response): Promise { try { const fileDataFromMulter = uploadFile(req); // Create an instance of the ProjectService const service = new FilesService(); const fileData = new Files(); fileData.file_name = fileDataFromMulter.filename; fileData.mime_type = fileDataFromMulter.mimetype; fileData.created_by = req?.user?.user_id ? req?.user?.user_id : null; const createdTask = await service.create(fileData); res.status(201).json(createdTask); res.status(200).json({ message: 'File uploaded successfully', fileData }); } catch (error) { res.status(400).json({ error: error.message }); } } public async getAllHandler(req: Request, res: Response): Promise { } public async getOneHandler(req: Request, res: Response): Promise { } public async updateHandler(req: Request, res: Response): Promise { } public async deleteHandler(req: Request, res: Response): Promise { } } From Postman, once you hit API, the following output will be displayed: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.9.jpg) **Figure 6.9:** Upload File API As per the code from the Multer middleware, the file is uploaded, and their data is stored in the database. Once the file data is stored, add thatfile_idin the task or comment module of thesupported_filesarray. * **Serve Uploaded Files** To get uploaded file, we will create an API that will begetOnefile, so updategetOneHandlerfunction in the controller with the following code: public async getOneHandler(req: Request, res: Response): Promise { try { const service = new FilesService(); const server_config: IServerConfig = config; const result = await service.findOne(req.params.id); ``const file_path =\({server_config.attached_files_path}/\){result.data.file_name};`` res.sendFile(file_path, (err) => { if (err) { // Handle errors, such as file not found or permission issues console.error('Error sending file:', err); res.status(500).json({ error: err.message }); } else { res.status(200).end(); } }); } catch (error) { res.status(400).json({ error: error.message }); } } This method called from the routes file as follows: app.route(this.baseEndPoint + '/:id') .all(authorize) .get(controller.getOneHandler); Once the API is triggered from Postman, the file associated with the directory will be downloaded directly, as follows: ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/6.10.jpg) **Figure 6.10:** Get Uploaded File By following these steps, you can use Multer to handle file uploads in your Node.js application, store files in a specific folder, and update file paths in your database. # Conclusion In this chapter, we crafted REST APIs for both the project and task modules, incorporating robust input validation and facilitating comprehensive CRUD operations. We also delved into the realm of advanced query capabilities with TypeORM, skillfully selecting specific columns to optimize performance. Moreover, we ventured into the territory of file uploads in Node.js, leveraging the powerful capabilities of the Multer package. In the next chapter, we will learn about API Caching, which is helpful in improving your application performance. # Further Reading [github.com/expressjs/multer](https://github.com/expressjs/multer) ``` ```js ````` js ```````

第七章

API 缓存

简介

用户打开浏览器并尝试访问应用程序的不同部分。这会导致对后端进行许多 API 调用。通常,用户数量较少时,响应会很快。然而,当应用程序中的数据增长并且大量用户同时访问数据时,响应时间会增加。这可能会导致用户体验不佳。

通常,对于一个系统,当数据的状态不经常变化时,对于给定的输入,输出将大部分相同,除非数据发生变化。在数据没有变化之前,如果你可以在某处保留结果的副本,那么就不需要重复从数据库中获取相同的数据。将此结果存储起来并在需要时访问的过程是缓存的基本概念。

在本章中,我们将学习如何缓存数据,并使用缓存的数据来服务 API 或保存查询数据,这样我们就不必在一定时间内或直到数据发生变化时查询数据库。

结构

在本章中,我们将讨论以下主题:

  • 理解缓存

  • Redis 简介

  • 设置 Redis 服务器

  • Redis/Caching 的优缺点

  • 使用 Redis 缓存数据

理解缓存

假设你正在尝试登录应用程序。在输入用户名和密码后,应用程序需要几秒钟来验证并导航到主页。在主页上,有许多内容:你参与的项目列表、项目任务上的团队活动、你的高优先级任务列表等等。如果系统已经忙于处理过多的请求,那么收集每个部分的数据将需要相当多的时间。

主页上的所有这些内容都需要从数据库中获取一些数据。每次你或其他用户打开应用程序时,数据库查询都会花费时间。这些数据库查询结果可以被缓存。如果被缓存,那么每次请求主页时,就可以避免数据库查询,直接从缓存中读取数据并发送响应。这种缓存-检索数据的过程会使响应更快,并提高用户体验。

根据定义,缓存是一种将获取的数据或计算结果存储在缓存中的技术,以便任何未来请求该数据时可以更快地提供服务。当我们需要访问数据时,首先会检查缓存以查看所需数据是否已缓存。如果是的话,则从缓存中提供数据。如果不是,则获取数据,如果需要则处理它,然后将其存储在缓存中,以便下一次请求可以更快地提供服务。

缓存是系统性能优化的关键组件。它有助于降低响应时间或延迟,并使系统具有高度的可扩展性。缓存可以被视为高速数据存储系统。有许多类型的缓存:内存缓存、磁盘缓存、浏览器缓存、数据库缓存等等。对于我们的用例,我们将缓存 API 响应所需的数据和数据库查询结果。对于缓存,我们将使用名为 Redis 的软件。

Redis 简介

引用 Redis.io 网站的内容:

“被数百万开发者用作数据库、缓存、流式引擎和消息代理的开源内存数据存储。”

简而言之,Redis 可以存储各种类型的数据。其中大多数是简单的键值对。它支持各种数据结构,如字符串、散列、列表、集合、有序集合、位图等。我们可以在 Redis 中存储这些类型的数据并通过键访问。

由于本质上是内存中的,Redis 在读写操作上非常快,因此成为缓存的热门选择。

除了帮助我们存储各种类型数据的核心数据结构外,Redis 还提供了包括数据复制、持久化、分片和事务功能在内的特性。Redis 被开发者用于各种应用。Redis 被 GitHub、Twitter、snapchat、stackoverflow 以及许多其他公司使用。Techstacks.io 维护了一个使用 Redis 进行用例的流行网站列表。

更多关于 Redis 的信息可以在 - redis.io 上学习。

设置 Redis 服务器

让我们先设置 Redis 服务器。本节将涵盖 MacOS、Ubuntu(debian) 和 Rocky Linux 的安装。

在 Mac OS 上安装 Redis 服务器

在 Mac 上安装大多数软件最简单的方法是使用 **homebrew**。如果 **homebrew** 没有安装,可以通过运行以下命令进行安装:

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

这将使一个名为 **brew** 的命令可用,我们可以使用它来安装 Redis 服务器。

brew install redis

一旦完成,可以使用以下命令启动服务器:

brew services start redis

要验证 Redis 是否已安装,我们可以尝试运行 **redis-cli**,这是访问 Redis 的命令行界面。

redis-cli

如果 Redis 安装成功,它将打开一个 Redis 提示符,如下所示:

127.0.0.1:6379>

如果提示符可见,Redis 已正确安装并准备好使用。

此外,我们可以执行一个 **ping** 命令,它应该响应为 **PONG**。如果这样做,那么一切正常。

127.0.0.1:6379> ping

PONG

在 Ubuntu / Linux 上安装 Redis 服务器

要在 Ubuntu 上安装 Redis,我们需要 **lsb-release****curl****gpg**。如果这些尚未安装,可以使用 **apt** 进行安装。

sudo apt install lsb-release curl gpg

现在,我们需要添加一个仓库,其中包含可用的 **redis** 二进制文件。

curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list

一旦添加了仓库,我们就可以安装 Redis:

sudo apt-get update

sudo apt-get install redis

之后,可以使用以下方式启动服务器:

sudo systemctl start redis

服务器启动后,类似于 Mac OS,我们可以使用 **redis-cli** 来验证。

在 Rocky 上安装 Redis 服务器(基于 RHEL)

我们可以使用 **dnf** 软件包管理器简单地安装 Redis。

sudo dnf update -y

sudo dnf install -y redis

安装完成后,服务可以启动如下:

sudo systemctl status redis

最后,我们可以使用 **redis-cli** 来验证。

缓存的优缺点

在我们继续使用 Redis 来实现我们的目的之前,了解缓存带来的优缺点是很重要的。缓存是一项关键技术,但它带来了一定的优点和缺点,我们在将其应用于应用时应该牢记在心。

缓存的优点

缓存的一些主要好处如下:

  • 性能提升:缓存显著减少了访问数据所需的时间,这使得对最终用户的响应更快。因此,它提供了更好的应用性能和改进的用户体验。

  • 降低负载:如果正确实施,大部分数据现在都从缓存中提供服务,这减少了应用上的负载。这样,应用可以处理更多的请求并更高效地运行。

  • 成本效益:有了缓存,单个服务器可以高效地处理更多的请求。考虑一下,如果没有缓存的服务器每分钟可以处理 100 个请求,而有了缓存,它可以每分钟处理 1000 个请求。当用户负载增加时,我们不需要添加更多的服务器。不仅服务器成本降低了,由于负载减少和带宽消耗降低,运营成本也降低了。

  • 减少网络流量:缓存服务器可以安装到应用所在的本地服务器上,或者安装到另一台服务器上。如果本地安装,它可以减少网络流量。然而,必须看到应用和缓存服务器是否可以保持在同一台机器上,而不会使资源消耗过高,例如内存和 CPU。

缓存的缺点

在开发过程中需要注意的一些缺点和要小心的事项如下:

  • 过时数据:确保缓存一致性很重要。如果数据过时,应该有有效的缓存失效策略来从缓存中删除旧的和无效的数据。考虑这样一个例子,你通过更新电话号码来更新你的个人资料。应用程序已经缓存了用户实体,它将你的个人资料数据保存在缓存中。当缓存的实体有更新时,旧数据必须从缓存中删除,并且新的数据必须放入其中。

    在缓存中存储数据时非常重要的是要小心。在所有数据可能不再有效的地方,应该将其删除或替换为更新后的数据。有时,当数据更新并不总是可能时,可以利用 TTL 这类功能。TTL 代表生存时间。通常,所有缓存系统都提供这一功能。这允许我们设置数据在缓存中应该保留的时间。一旦时间到期,数据将自动失效。

  • 资源消耗:如果应用程序中有大量数据,重要的是要决定我们缓存什么。只有最常需要的数据才应该被缓存。否则,在资源受限的环境中,内存、磁盘空间和其他资源可能会成为问题。

  • 缓存未命中:当我们尝试访问某些数据但未在缓存中找到时,这被称为缓存未命中。了解如何处理它很重要。如果数据在缓存中找不到,应用程序必须回退到原始数据源。此外,当从原始源获取数据时,它应该被放入缓存以更快地服务未来的请求。

    处理像缓存未命中这样的情况至关重要,因为它在时间和资源方面可能代价高昂。如果有太多的请求,并且有太多的缓存未命中,这将导致应用程序效率低下。

  • 数据同步问题:对于分布式环境,保持缓存数据和原始数据源同步可能是一个挑战。如果有多个缓存,这个问题会更大。

  • 复杂性:总体而言,实现缓存可能会给系统增加更多的复杂性。它也可能导致额外的开发工作以及测试和维护工作。

使用 Redis 进行缓存

在我们的项目管理系统中,到目前为止,我们已经实现了几个模块:用户、角色、项目和任务。假设这个应用程序被一个拥有 10 万名用户的组织使用。在办公时间开始时,通常上午 9 点,人们将登录并访问他们的项目,了解他们的任务并取得进展。如果我们缓存一些东西,响应时间将得到改善,用户体验将更好。

缓存有许多策略,例如按需缓存和主动缓存。主动缓存是指在应用程序启动时缓存某些内容,而不需要任何用户请求。这种缓存是在预期未来请求的情况下进行的。另一方面,每当访问某个内容并且是缓存未命中情况时,此时的缓存就是按需的。在按需缓存的情况下,最初不会有任何记录,只有在缓存未命中后才会缓存对象。

在我们的应用程序中,我们可以混合使用这两种策略。当应用程序启动时,我们可以缓存所有用户、角色、项目及其任务。可能会有一些特定的情况,当我们想要缓存的数据不是直接的数据库查询结果时,例如,如果你想要所有项目的项目计数和相应地所有项目的任务计数。这些值将是某些函数的结果,我们可以将其缓存。在本节中,我们将看到如何缓存我们感兴趣缓存的两类数据。

总体来说,我们希望以以下方式存储数据:

  • 对象:本质上是指项目的、任务的、用户的等 JSON 对象。

  • 数字:项目、任务、用户等的计数。

可能还有更多。

更新项目依赖

首先,我们需要更新项目依赖,即我们案例中的节点模块。我们只需要一个包,那就是 Redis。让我们使用**npm** **install**来安装 Redis:

npm install redis

在开发过程中,安装 Redis 的类型定义会更好,这样我们就能得到适当的代码提示。

npm install --save-dev @types/redis

当 Redis 包可用后,我们可以像以下示例那样使用它:

import { createClient, RedisClient } from 'redis';

const client: RedisClient = createClient();

client.on('connect', () => {

console.log('Connected to Redis');

});

client.set('key', 'value', (err, reply) => {

if (err) throw err;

console.log(reply); // OK

});

client.get('key', (err, reply) => {

if (err) throw err;

console.log(reply); // value

});

client.quit();

在前面的例子中,我们导入了**createClient**函数和**RedisClient**类型。接下来,我们使用**createClient()**函数调用创建了一个客户端,并使用**client.on()**函数尝试连接到 Redis 服务器。

在讨论的例子中,我们使用了回调,但我们也可以使用 async-await。

使用**client.set()**,我们可以设置一个键值对。这将把键值对存储到作为缓存服务器的 Redis 服务器上。当需要时,我们可以使用**client.get()**获取键的值。

在这里,我们存储了一个简单的字符串 **'value'**,如果我们想存储一个对象,我们必须使用 **JSON.stringify()** 将对象转换为字符串,并将其作为字符串存储。所有这些都是因为 Redis 不直接支持 JSON 对象作为值。为了使 Redis 存储 JSON 对象,我们需要设置一个名为 **RedisJSON** 的 Redis 模块。此模块为 Redis 提供了本地的 JSON 功能。

**RedisJSON** 模块可在 GitHub 上找到:**https://github.com/RedisJSON/RedisJSON**

我们需要克隆此仓库或下载仓库的 zip 文件。在继续之前,请确保已安装 Rust。

如果不可用,可以使用以下方式安装 rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

此步骤完成后,我们可以通过检查 rust 版本来验证安装。

rustc --version

安装完成后,我们可以开始构建 **RedisJSON**。导航到下载的仓库并运行以下命令:

cargo build –release

这将在 **target/release/librejson.so** 中创建模块。如果文件缺失,则表示出了问题,请再次尝试构建。

我们需要修改 Redis 配置,通常位于 **/etc/redis/redis.conf****/usr/local/etc/redis.conf**,并添加对 **redisjson.so** 模块的启用。

在 Linux 上

loadmodule /path/to/redisjson.so

在 Mac 上

loadmodule /path/to/rejson.dylib

模块加载后,重新启动 Redis 以启用它。

缓存工具

由于我们需要缓存大量实体,使用缓存工具类可能是有益的。这个类理想情况下应该提供设置和检索缓存值的函数。

一旦我们有了缓存工具类,我们就可以直接在需要的地方导入它并使用其功能。让我们创建一个名为 **cache_util.ts** 的文件,其中包含以下代码:

// cache_util.ts

import * as redis from 'redis';

export class CacheUtil {

// redis client instance

private static client = redis.createClient();

constructor() {

CacheUtil.client.connect();

}

public static async get(cacheName: string, key: string) {

try {

const data = await CacheUtil.client.json.get(`${cacheName}:${key}`);

return data;

} catch (err) {

console.error(`Error getting cache: ${err}`);

return null;

}

}

public static async set(cacheName: string, key: string, value) {

try {

await CacheUtil.client.json.set(`${cacheName}:${key}`, '.', value);

} catch (err) {

console.error(`Error setting cache: ${err}`);

}

}

public static async remove(cacheName: string, key: string) {

try {

await CacheUtil.client.del(`${cacheName}:${key}`);

} catch (err) {

console.error(`Error deleting cache: ${err}`);

}

}

}

函数 **set()** 可以用于在缓存中设置键值对,而 **get()** 函数可以用于检索值。注意我们是如何使用 **client.json.get()****client.json.set()** 实际上获取和设置值的。这两个函数都被设置为静态的,这样我们就可以直接使用它们,而无需每次都初始化类。

我们现在可以初始化类一次并在任何地方使用它。我们需要这样做是为了将 Redis 客户端连接到服务器。如果没有调用 **CacheUtil.client.connect()**,应用程序将抛出错误:客户端已关闭。

现在,当缓存工具准备就绪时,让我们在 **main.ts** 中初始化它。

// main.ts

// 初始化缓存工具

new CacheUtil();

这将调用构造函数并将客户端连接到 Redis 服务器。

缓存实体

我们之前讨论了两种实体缓存方法:按需和主动。对于按需缓存,我们可以更新所有控制器的函数,首先检查所需数据是否在缓存中。以下示例显示了 **users_controller****getOneHandler**,它负责根据给定的用户 ID 返回用户:

// users_controller.ts

public async getOneHandler(req: Request, res: Response): Promise<void> {

if (!hasPermission(req.user.rights, 'get_details_user')) {

res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });

return;

}

// 检查用户是否在缓存中

**const userFromCache = await CacheUtil.get(**'**User**'**, req.params.id);**

if (userFromCache) {

res.status(200).json({ statusCode: 200, status: 'success', data: userFromCache });

return;

} else {

// 从数据库获取用户

const service = new UsersService();

const result = await service.findOne(req.params.id);

if (result.statusCode === 200) {

delete result.data.password;

// 在缓存中设置用户

**CacheUtil.set(**'**User**'**, req.params.id, result.data);**

}

res.status(result.statusCode).json(result);

return;

}

}

在该函数中,我们调用 **CacheUtil.get()****'User'** 作为缓存名称,键是请求参数中提供的 **user_id**。有可能请求的用户不在缓存中,因此必须检查返回的值 **userFromCache** 是否为 null。如果是 null,则应采取常规流程并从数据库中获取用户。如果用户存在于数据库中,则还应将其保存到缓存中。以下调用 **CacheUtil.set()** 的行为我们做了这件事:

**CacheUtil.set('User', req.params.id, result.data);**

这样,所有函数都可以更新为在实际数据库查询之前检查缓存。对于删除 API 被调用的案例,缓存中的值也必须被删除。如果值没有被删除,未来通过**user_id**获取用户的 API 调用将返回一个在数据库中不再存在的值。

The delete` function can be updated as:

public async deleteHandler(req: Request, res: Response): Promise<void> {

if (!hasPermission(req.user.rights, 'delete_user')) {

res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });

return;

}

const service = new UsersService();

const result = await service.delete(req.params.id);

// remove user from cache

**CacheUtil.remove(**'**User**'**, req.params.id);**

res.status(result.statusCode).json(result);

return;

}

调用**CacheUtil.remove()**将从缓存中删除用户。

使用这种按需方法进行缓存,确保常用数据保持在缓存中,而未使用的数据不会填满缓存。然而,在这种情况下,当数据第一次被请求时,总会发生缓存未命中。

Building Cache at Startup

有时,在应用程序启动时填充缓存是好的。这有助于防止一些缓存未命中,因为请求的数据将存在于缓存中。让我们更新**UsersUtil**以添加一个函数**putAllUsersInCache()**,该函数将从数据库中检索所有用户并将它们放入缓存。

// function to put all users in cache

public static async putAllUsersInCache() {

const userService = new UsersService();

const result = await userService.findAll({});

if (result.statusCode === 200) {

const users = result.data;

users.forEach(i => {

CacheUtil.set('User', i.user_id, i);

});

console.log(`All users are put in cache`);

}else{

console.log(`Error while putAllUsersInCache() => ${result.message}`);

console.log(result);

}

}

此函数正在使用**userService**调用**findAll()**来从数据库中获取所有用户,如果查询结果成功,则使用**forEach**将用户放入缓存。

我们可以从**main.ts**调用此函数。

// Proactive cache update

setTimeout(() => {

UsersUtil.putAllUsersInCache();

}, 1000 * 10 );

当一个应用程序启动时,它可能需要时间来建立与数据库、缓存服务器的连接,并执行其他初始化操作。在开始其他操作之前,为这些连接和初始化操作留出足够的时间是明智的,以确保应用程序的平稳运行。因此,我们使用**setTimeout**添加了一些延迟(这里为 10 秒)。

类似于**UsersUtil**,其他实体工具也可以修改以添加一个功能,然后我们可以从**main.ts**中调用这些功能。这些功能不一定要从**main.ts**文件中调用。我们可以将它们放在其他地方,例如**CacheUtil**,然后从**main.ts**中调用**CacheUtil****init**缓存。同样,它可以是**putAllXXToCache**之外的另一个功能。

使用 Redis 时的注意事项

尽管 Redis 是一个强大的内存数据存储,但在使用 Redis 时仍有一些挑战。以下是一些挑战的讨论点:

  • 内存限制

    由于它是一个内存数据存储,它提供了更快的访问速度,但受限于系统容量。如果您有一个大数据集,目标是尽量减少成本,那么这可能会是一个缺点。

  • 数据安全

    默认情况下,Redis 未加密。尽管它支持基于密码的简单认证,但没有内置的 SSL/TLS 加密支持。为了保护 REST API 的数据,需要额外的工具。

  • 查询数据的方式有限

    与传统的数据库系统相比,Redis 的查询能力有限。然而,通过在应用层实现一些额外的逻辑,可以执行复杂的查询。

  • 单线程特性

    Redis 服务器本质上是单线程的。如今的大多数机器都是多核的,Redis 无法利用超过一个核心。然而,较新的 Redis 版本将慢查询放到单独的线程中,但 Redis 服务器的请求仍然只由一个线程处理。

  • 云上的托管成本

所有主要云平台都提供 Redis 作为服务。例如,AWS 的**Elasticache**、Azure 的 Redis 缓存都是一些例子。对于大数据集,这些服务可能很昂贵。

如果 Redis 不适合您的用例,还可以探索其他选项,如 Memcached、Apache Kafka 等。

结论

缓存可以提高用户体验,极大地提升应用程序的性能,同时使其更加稳定和可扩展。在本章中,我们熟悉了缓存的概念,并设置了 Redis,它是任何规模应用程序缓存的流行选择。我们学习和实现了两种缓存策略:按需缓存和主动缓存。

在下一章中,我们将实现通知模块,同时学习 Redis 的另一个方面:消息队列。

多项选择题

  1. 实现应用程序中缓存的 主要目标是什么?

    1. 为了增加数据处理时间。

    2. 为了安全地存储用户密码。

    3. 为了减少访问数据所需的时间。

    4. 为了增加网络流量。

  2. 缓存处理数据库查询的显著好处是什么?

    1. 它增加了应用服务器的负载。

    2. 它通过从缓存中获取数据来帮助避免数据库查询。

    3. 它降低了应用程序的性能。

    4. 它消耗更多的带宽。

  3. 以下哪项不是缓存的优点?

    1. 性能提升。

    2. 减轻应用程序的负载。

    3. 增加网络流量。

    4. 成本效益。

  4. 在缓存的环境中,TTL 代表什么?

    1. 启动时间

    2. 存活时间

    3. 总时间限制

    4. 加载时间

  5. 什么是“缓存未命中”?

    1. 当数据成功从缓存中检索时。

    2. 当数据在缓存中未找到时。

    3. 当缓存被完全利用时。

    4. 当缓存无法保存数据时。

  6. 在分布式环境中缓存的关键挑战是什么?

    1. 简化用户界面。

    2. 减少用户数量。

    3. 保持缓存数据和原始数据源同步。

    4. 降低服务器成本。

  7. 在启动时构建缓存的优点是什么?

    1. 它不必要地增加了缓存大小。

    2. 它确保频繁请求的数据立即可用。

    3. 它会减慢应用程序的启动速度。

    4. 它需要较少的开发工作量。

  8. 在描述的场景中,哪种软件用于缓存?

    1. MySQL

    2. Redis

    3. MongoDB

    4. Oracle

答案

  1. c

  2. b

  3. c

  4. b

  5. b

  6. c

  7. b

  8. b

进一步阅读

www.geeksforgeeks.org/caching-system-design-concept-for-beginners/

redis.io

redis.io/docs/data-types/json/

www.npmjs.com/package/redis

第八章

通知模块

简介

交流是涉及团队合作的过程的核心。

当项目中的任何活动发生时,都会向相关用户发送通知。开发者会收到任务分配的通知。当任务被移至测试时,测试员会收到通知。每当有新评论添加到特定任务时,都会向所有关注该任务的用户发送通知。

通知可以有多种类型。电子邮件、短信和用户界面内部通知是最常见的。本章将继续我们的开发路径,添加一个通知模块。

结构

在本章中,我们将讨论以下主题:

  • 理解通知模块

  • 实现队列

  • 通知新任务

理解通知模块

通知在整个项目生命周期中扮演着关键角色,它向相关利益相关者通报正在进行中的变更和活动。当一项新任务分配给开发者时,一封电子邮件通知可以突出显示任务内容,确保开发者及时得到通知和更新。一个质量保证团队成员可以收到关于任务被移至测试阶段的通知。

通知在项目进度中发挥着重要作用。及时收到的通知可以加快团队间的沟通。

通知的媒介可以取决于许多因素,例如紧急性、通信的紧迫性、项目的性质等等。

通常,有三种类型的通知:电子邮件、短信和在应用内或用户界面内部通知(针对网站)。在本章中,我们将重点关注电子邮件,但这个过程也将为其他类型通知的实施奠定基础。

让我们从在 **src/util** 中创建一个新文件 **notification_util.ts** 开始实施。该文件将包含一个类,该类将包含发送通知的实用方法。

// 路径:src/utils/notification_util.ts

export class NotificationUtil {

js `}` This class will be our single point from where all types of notifications can be sent. From anywhere in the code, we can simply make a call to this class. Let us add the functionality for sending an email. We already implemented a function in the `**email_util.ts**` class. That function can act as a reference. For sending an email, we will use a node package, `nodemailer`. `import * as nodemailer from 'nodemailer';` Let us add the constructor to the util `**NotificationUtil**` as: `// Path: src/utils/notification_util.ts` `import * as nodemailer from 'nodemailer';` `export class NotificationUtil {` `// nodemailer transporter instance` `private static transporter;` `constructor(config) {` `if (!config) {` `throw new Error('Config not provided');` `}` `if (!NotificationUtil.transporter) {` `NotificationUtil.transporter = nodemailer.createTransport({` `service: 'gmail',` `auth: {` `user: config.email_config.user,` `pass: config.email_config.password` `}` `});` `}` `}` `}` In the preceding code, we have a constructor which takes a config object as the only argument. We will use this config to retrieve the necessary config for sending an email such as SMTP server username, password, and so on. We have created a transporter object as a private static member of the class. In the context of Node.js and `**nodemailer**`, a transporter is an object which encapsulates the email sending functionality. Simply put, it is a way to send email using Node.js in which we do not have to worry about the low level details of the process. A transporter object is usually created once and reused. Hence, we have the object created at class level. In the constructor, we first check if this is already initialized or not. If the object is not initialized, we do it. In our case, we are using gmail as a server but other providers and generic SMTP servers can also be used. To know more about the `**nodemailer**`, visit the website [`nodemailer.com/`](https://nodemailer.com/). Further, we can add our function to send emails. For sending an email, we need the sender email address, recipient email address, subject, email body. Since the sender’s email address is unlikely to be changed for every email, let us add that as a private static variable of the class and we can set that inside the constructor. `private static from: string;` In the constructor, we can add the following line after creating the transporter : `NotificationUtil.from = config.email_config.from;` Let us define our function to receive these values as arguments. `public async sendEmail(to: string, subject: string, body: string) {` `try {` `const mailOptions = {` `from: NotificationUtil.from,` `to: to,` `subject: subject,` `html: body` `};` `const status = await` `NotificationUtil.transporter.sendMail(mailOptions);` `if (status?.messageId) {` `return status.messageId;` `} else {` `return false;` `}` `} catch (error) {` ``console.log(`Error while sendEmail => ${error.message}`);`` `return false;` `}` `}` For the `**sendEmail**` function, we receive just what we need — `**to**` (recipient email address), `**subject**`, and `**body**` of the email. We create an object `**mailOptions**` using these four values and finally send email using `**transporter.sendEmail()**`. If this function successfully sends an email, we will receive a message Id which we can return, otherwise, we will return a `**false**` boolean value indicating that something did not go right. This completes our email sending feature with the help of `**NotificationUtil**`. We can now use this function instead of the `**sendMail**` function from `**email_util.ts**`. We are using the `**sendMail**` function from `**email_util**` for the `**forgotPassword**` function in `**UserController**`. Let us change that to use `**NotificationUtil**` by importing the `**util**` in `**UserController**`. `import { NotificationUtil } from '../../utils/notification_util';` Now, we can modify the `**forgotPassword**` function to use notification `**util**`. We need to replace the following line: `const emailStatus = await sendMail(mailOptions.to, mailOptions.subject, mailOptions.html);` The line replacing would be as follows: `const emailStatus = await NotificationUtil.sendEmail(mailOptions.to, mailOptions.subject, mailOptions.html);` Instead of `**sendMail**`, we are using `**NotificationUtil.sendEmail**` function. Rest everything remains the same. We can also remove the unused `**import**` `import { sendMail } from '../../utils/email_util';` We also need to initialize the `**NotificationUtil**` from our `**main.ts**` file. `new NotificationUtil(config);` The config can be imported as: `import * as config from '../server_config.json';` # Implementing Queue In a large organization there would be a good number of people using project management software. From each user, there would be tons of activities and some of those activities would require an email notification to be sent to other users. If there are a lot of emails to be sent, it is better to handle the communication using a queue. A queue is a mechanism where each call to send email would be added and the queue will be processed separately along with a failure mechanism. Using a queue in this manner is a good approach for the following reasons: * **Improved performance and efficiency**: Sending an email directly can be resource-intensive and may slow down the primary operation. A queue would allow decoupled handling of the email sending process and the response times would be faster. * **Scalability:** As the number of users grows, activities and number of emails to be sent would grow significantly. A queue can handle the increased load. There can be different strategies to handle a queue, for example, separate notification server, separate worker, and so on. * **Reliability:** If an email fails to send, we can easily retry after sometime, if this is carried out through a queue. The failure can be due to many reasons such as server issues and network problems. The queue can be designed to handle failures and be equipped with a mechanism to retry. * **Asynchronous processing:** Queues can be made asynchronous so that the application does not have to wait while email is being sent and we receive a response from the email server. These are some of the few key reasons why it is a good idea to use queues for sending emails. There can be incidents when we need immediate response to an email sending call and without that we cannot ensure reliability. One such example is the `**forgotPassword**` function which we implemented. In this case, as soon as the user provides their email, we verify things at the backend and send an email. The user would expect an email immediately in his/her mailbox. In such cases, we can also use hybrid methods for email notifications. In our application, we will also do the same thing. For actions needing immediate email, we will use the `**sendEmail**` function from `**NotificationUtil**` and for the actions which can wait, such as notifying users about a new comment on the task, or when a new task is created, or when a task is moved in the workflow (for example, `**Backlog → ToDo → Dev-Complete → Ready-to-test → Closed**`), and so on, we will use the queue. Let us move to implement the queue in our `**NotificationUtil**`. For the queue, we need to store the queued objects somewhere outside of the application so that if for any reason the application fails, we do not lose the objects in the queue. For this purpose, we can use Redis. # Using Redis for Queue Redis is a powerful in-memory data store that supports necessary data structures and features to implement a basic queue system. The following code snippet shows how a queue can be implemented and used: `const redis = require('redis');` `const client = redis.createClient();` `// Adding a job to the queue` `client.lpush('emailQueue', JSON.stringify({` `from: 'pms-support@pms.com',` `to: 'pmsbook2023@gmail.com',` `subject: 'Welcome to PMS',` `text: 'Welcome to PMS. We are happy to have you on board.'` `}));` `// Processing jobs from the queue` `const processJob = () => {` `client.brpop('emailQueue', 0, (err, reply) => {` `if (err) {` `// Handle error` `} else {` `const job = JSON.parse(reply[1]);` `// Process job` `console.log('Processing job:', job);` `// Continue processing next job` `processJob();` `}` `});` `};` `// Start processing` `processJob();` In this code, we created a client using `**redis.createClient()**` at first. This client can be used to push jobs to the queue using `**lpush**`. This call will maintain the records for our processing later. While processing the jobs, we can fetch the items from the same queue using `**brpop**` and process. This can be used in any functionality and not just for sending email. The `**lpush**` and `**brpop**` functions are specific to Redis. In the preceding code, there is a comment added for handling the error. There can be many ways to handle the error while processing the queue. Once we use `**client.brpop**` it will remove the message from the queue and make it available for processing. If processing of the removed message was a failure, it must be handled in a proper manner. One way to handle this situation can be to retry the processing again after some time. In this case, we need to save the failed message somewhere so that it can be processed later. Another way can be to simply add a log using console.log and notify the respective stakeholder. If there is an alarm system in place an alert can also be raised. The approach is simple, we queue something using `**lpush**` and retrieve using `**brpop**` for processing. This implementation helps us to process emails but lacks error handling, retries, maintenance, and much more of what a sophisticated node package such as Bull can provide. We will use the Bull node package for our implementation. Bull is a popular Node.js library used for handling background jobs and job queues. Bull is built on top of Redis. Some of the key features of Bull are robustness, job scheduling, concurrency control, retry mechanism, event driven, rate limiting, persistence using redis, and so on. Further information about the package is available at [`optimalbits.github.io/bull/`](https://optimalbits.github.io/bull/) . For our use case, where we want to queue emails, process the queue, handle failures, and improve efficiency, Bull is an excellent choice. Let us start with the implementation by installing and then adding Bull to our application. `npm install bull` After installing Bull, we can import it in the `**NotificationUtil**`. `import Queue from 'bull';` We need to create a `**queue**` for emails: `private static emailQueue = new Queue('emailQueue', 'redis://127.0.0.1:6379');` We also need to add a function which can be called from other parts of the application to enqueue an email job. `// Function to enqueue email tasks` `public static async enqueueEmail(to: string, subject: string, body: string) {` `// Enqueue the email task` `await NotificationUtil.emailQueue.add({` `to,` `subject,` `body` `});` `}` This is all for enqueuing an email. We just need to use the `**emailQueue.add()**` function to add the object which contains necessary information we need while sending the email. We need to add logic to process the queue asynchronously. For smaller applications the logic to handle the queue can be part of the application itself. However, when applications grow bigger it is better to put the queue handling as a separate `**worker/process**`. Let us add a new file for queue workers as `**queue_worker.ts**` in a new directory workers. File path would be `**src/workers/queue_worker.ts**`. `import Queue from 'bull';` `import { NotificationUtil } from '../utils/notification_util';` `export class QueueWorker {` `private static emailQueue = new Queue('emailQueue', 'redis://127.0.0.1:6379');` `constructor() {` `console.log('Initializing QueueWorker');` `}` `public beginProcessing() {` `QueueWorker.emailQueue.process(async (job) => {` `try {` `const { to, subject, body } = job.data;` `const responseEmail = await NotificationUtil.sendEmail(to, subject, body);` `if (!responseEmail) {` `// handle error` `}` ``console.log(`Email sent to ${to}`);`` `} catch (error) {` `// handle error` ``console.error(`Failed to send email: ${error.message}`);`` `}` `});` `}` `}` The preceding class defines a function to begin processing of the `**emailQueue**` defined as a static member of the class. In the class, while processing jobs to send email we just make a call to `**NotificationUtil.sendEmail()**` function. # Handling Failures It can happen that sometimes emails are not sent and we need to retry. The `**QueueWorker**` should be capable of handling such incidents. Let us modify the `**beginProcessing**` function to add support for failed jobs. `public beginProcessing() {` `QueueWorker.emailQueue.process(async (job) => {` `// existing logic ..` `});` `QueueWorker.emailQueue.on('failed', async (job, err) => {` `// Retry the job` ``console.log(`Retrying job for ${job.data.to}`);`` `await job.retry();` `});` `}` The `**QueueWorker.emailQueue.on**` function call with `'**failed**'` status gets executed whenever there is a failed job. In this case, we can retry the job using `**job.retry()**`. There can be cases, when a failed job fails many more times. Basically, it is never going to succeed. Such cases should be handled with a check on how many attempts were made to run the same task. To fix this, we first need to define a max attempt count. `private static MAX_ATTEMPTS = 4;` Now, we need to modify the function again to check if the number of attempts made by a job exceeds the `**MAX_ATTTEMPTS**` or not. `QueueWorker.emailQueue.on('failed', async (job, err) => {` `if (job.attemptsMade >= QueueWorker.MAX_ATTEMPTS) {` `// Handle the final failure` ``console.error(`Job permanently failed for ${job.data.to}: ${err.message}`);`` `}else {` `// Retry the job` ``console.log(`Retrying job for ${job.data.to}`);`` `await job.retry();` `}` `});` This will retry the failed job for four times and then exit if it does not succeed in four attempts. We should handle the case when the job finally fails. This can be done by some other kind of notification, proper reporting in logs, or recording the incident in a persisted mode, for example, in a database table. # Notifying About the New Task Till this point, we have a basic implementation of the queue for email notifications. We can now use it for other parts of the application. As an example, let us try to add notifications for all members of a project whenever there is a new task created. Let us first add the following function in `**ProjectsUtil**` : `public static async getProjectByProjectId(project_id: string) {` `const projectService = new ProjectsService();` `const project = await projectService.findOne(project_id);` `return project.data;` `}` To send a notification, we need to first get the users of the project and we can get it from the project object. After this, let us update the `**addHandler()**` in `**TaskController**` inside `**task_controller.ts**` file. `public async addHandler(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req?.user?.rights, 'add_task')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `try {` `// Create an instance of the TaskService` `const service = new TasksService();` `// Extract task data from the request body` `const task = req.body;` `// Get the project` `const project = await ProjectsUtil.getProjectByProjectId(task.project_id);` `//check if the provided project_id is valid` `const isValidProject = project ? true : false;` `if (!isValidProject) {` `// If user_ids are invalid, send an error response` `res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid project_id' });` `return;` `}` `// Check if the provided user_id is valid` `const isValidUser = await UsersUtil.checkValidUserIds([task.user_id]);` `if (!isValidUser) {` `// If user_ids are invalid, send an error response` `res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid user_id' });` `return;` `}` `// If user_ids are valid, create the task` `const createdTask = await service.create(task);` `res.status(201).json(createdTask);` `// task is created, now send email to the user` `const userIds = project.user_ids;` `// for each user_id, enqueue an email task` `for (const userId of userIds) {` `const user = await UsersUtil.getUserById(userId);` `if (user) {` `await NotificationUtil.enqueueEmail(` `user.email,` `'New Task Created',` `` `A new task has been created with the title ${task.title} and description ${task.description}`); `` `}` `}` `} catch (error) {` `// Handle errors and send an appropriate response` ``console.error(`Error while addUser => ${error.message}`);`` `res.status(500).json({ statusCode: 500, status: 'error', message: 'Internal server error' });` `}` `}` The function we need at `UserUtil` to get user by user_id is - `public static async getUserById(user_id: string) {` `const userService = new UsersService();` `const queryResult = await userService.findOne(user_id);` `if (queryResult.statusCode === 200) {` `const user = queryResult.data;` `return user;` `}` `return null;` `}` The logic of sending email can also be moved to a separate function. We can add a `**utility**` class for Tasks inside `**tasks_controller.ts**` similar to projects and users. `export class TaskUtil {` `// Notify the users of the project that a change` `public static async notifyUsers(project, task) {` `if (project) {` `const userIds = project.user_ids;` `for (const userId of userIds) {` `const user = await UsersUtil.getUserById(userId);` `if (user) {` `await NotificationUtil.enqueueEmail(` `user.email,` `'New Task Created',` `` `A new task has been created with the title ${task.title} and description ${task.description}` `` `);` `}` `}` `}` `}` `}` The preceding function takes a project and task object and notifies all users. We can make a call to this function from the `**addHandler**`. The updated `**addHandler()**` function would be: `public async addHandler(req: Request, res: Response): Promise<void> {` `if (!hasPermission(req?.user?.rights, 'add_task')) {` `res.status(403).json({ statusCode: 403, status: 'error', message: 'Unauthorised' });` `return;` `}` `try {` `// Create an instance of the TaskService` `const service = new TasksService();` `// Extract task data from the request body` `const task = req.body;` `// Get the project` `const project = await` `ProjectsUtil.getProjectByProjectId(task.project_id);` `//check if the provided project_id is valid` `const isValidProject = project ? true : false;` `if (!isValidProject) {` `// If user_ids are invalid, send an error response` `res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid project_id' });` `return;` `}` `// Check if the provided user_id is valid` `const isValidUser = await UsersUtil.checkValidUserIds([task.user_id]);` `if (!isValidUser) {` `// If user_ids are invalid, send an error response` `res.status(400).json({ statusCode: 400, status: 'error', message: 'Invalid user_id' });` `return;` `}` `// If user_ids are valid, create the task` `const createdTask = await service.create(task);` `res.status(201).json(createdTask);` `// Notify the users of the project that a new task has been created` `await TaskUtil.notifyUsers(project, task);` `} catch (error) {` `// Handle errors and send an appropriate response` ``console.error(`Error while addUser => ${error.message}`);`` `res.status(500).json({ statusCode: 500, status: 'error', message: 'Internal server error' });` `}` `}` We can re-use the same function for update and delete handlers by a simple modification. We can pass an argument ``**action**`` which takes one value out of `**add**`, `**update**`, `**delete**`. Based on this action, we can modify the subject and content of the email. Following is the updated function: `public static async notifyUsers(project, task, action) {` `if (project) {` `const userIds = project.user_ids;` `let subject = '';` `let body = '';` `if (action === 'add') {` `subject = 'New Task Created';` ``body = `A new task has been created with the title ${task.title} and description ${task.description}`;`` `} else if (action === 'update') {` `subject = 'Task Updated';` ``body = `A task has been updated with the title ${task.title} and description ${task.description}`;`` `} else if (action === 'delete') {` `subject = 'Task Deleted';` ``body = `A task has been deleted with the title ${task.title} and description ${task.description}`;`` `}` `for (const userId of userIds) {` `const user = await UsersUtil.getUserById(userId);` `if (user) {` `await NotificationUtil.enqueueEmail(` `user.email,` `subject,` `body` `);` `}` `}` `}` `}` Here, we are making the subject and body of the email based on the action provided. We also need to modify the call accordingly: `// Notify the users of the project that a new task has been created` `await TaskUtil.notifyUsers(project, task, 'add');` Similar to `**addHandler**`, we can make call from `**updateHandler()**` and `**deleteHandler()**` `**deleteHandler()**` as: `await TaskUtil.notifyUsers(project, task, 'update');` `await TaskUtil.notifyUsers(project, task, 'delete');` # Considerations while Implementing Queues There is a set of challenges to consider while implementing a queue. Let us discuss a few of those here: * **Scalability** When it comes to handling high volumes of messages, ensuring the smooth handling can be challenging. In such cases, using multiple queues (spread across different machines) to balance the load could be helpful. * **Latency and Throughput** Throughput is the number of messages processed in a given timeframe. When there is a high volume of messages to process and the application flow is critical it becomes vital to ensure that the latency is within permissible limits. Higher latencies can be a bottleneck in real-time applications. To tackle such situations requires optimizations at application, queue and network level to achieve desirable throughput. * **Fault Tolerance and Recovery** There can be cases when due an issue in the application the queue stops processing the messages. The messages must still be processed after the application has recovered from error. There should be a mechanism so that the messages in queue (waiting to be processed) are persisted or kept safe. There are other considerations, for example, ordering and consistency of delivery of messages, security, monitoring while implementing the queue system. With help of careful design of the application these problems can be avoided, or in worst case, mitigated. # Conclusion This chapter introduced a new concept `'Queue'` which is very important to handle processing of various types of data. In our case, we used it for email notifications. The `**NotificationUtil**` and `**QueueWorker**` together implemented the queue mechanism with the help of the `Bull`, a `**Node.js**` library built on top of Redis. In the next chapter, we will learn how an application can be built for production and be deployed on real servers. We will also learn to obfuscate the code so that, if it falls in wrong hands, it will not reveal everything and make the job harder for an outsider to crack. # Multiple Choice Questions 1. What is the primary purpose of notifications in a project lifecycle? 1. To provide entertainment. 2. To inform stakeholders about changes and activities. 3. To gather feedback. 4. To schedule meetings. 2. How does a queue contribute to the scalability of email sending in a project management system? 1. By limiting the number of users. 2. By reducing the number of emails sent. 3. By handling increased load effectively. 4. By sending all emails immediately. 3. What is a key advantage of a queue in terms of reliability for email notifications? 1. It guarantees email delivery on the first attempt. 2. It simplifies email content. 3. It allows for retrying failed email sends. 4. It uses less server resources per email. 4. Why is Redis chosen for implementing the queue in NotificationUtil? 1. For its complexity. 2. Because it is an in-memory data store with suitable features. 3. Solely for cost-saving purposes. 4. For its slow processing speed. 5. What is the purpose of a transporter in Node.js and nodemailer? 1. To store email templates. 2. To encapsulate the email sending functionality. 3. To manage database connections. 4. To encrypt email content. 6. How does a timely received notification affect team communication? 1. It has no significant impact. 2. It slows down communication. 3. It speeds up communication. 4. It complicates communication. 7. What factors influence the medium of notification? 1. Developer’s preference. 2. Time of the day. 3. Urgency, criticality of communication, nature of the project. 4. Cost of the notification system. 8. How is the transporter object typically used in a Node.js application? 1. Created for each email sent. 2. Created once and reused. 3. Only used for receiving emails. 4. Initialized in every function call. # Answers 1. b 2. c 3. c 4. b 5. b 6. c 7. c 8. b # Further Readings [`nodemailer.com/`](https://nodemailer.com/) [`redis.io/`](https://redis.io/) [`www.npmjs.com/package/redis`](https://www.npmjs.com/package/redis) [`redis.com/glossary/redis-queue/`](https://redis.com/glossary/redis-queue/) [`en.wikipedia.org/wiki/Message_queue`](https://en.wikipedia.org/wiki/Message_queue)

第九章

测试 API

介绍

Node.js 是一个多功能的运行环境,允许开发者将 JavaScript 运行在服务器端。当涉及到测试 REST API 时,Node.js 提供了众多库和工具来简化这一过程。使用 Node.js,你可以编写与你的 RESTful API 交互的自动化测试,发送 HTTP 请求,并验证响应。它提供了一个灵活且可扩展的平台来运行 API 测试,使其成为单元测试和集成测试的绝佳选择。

Node.js 还允许你利用各种测试框架和库,如 Mocha、Chai、Jest 和 Supertest。这些工具简化了测试套件的创建、断言检查和 API 端点的测试运行器。此外,Node.js 的异步特性非常适合进行 HTTP 请求和处理异步响应,这在测试 API 中至关重要。这种异步能力确保你的测试可以高效地同时处理多个请求和响应。

在本章中,我们将探讨创建测试用例和执行 API 验证的过程。

结构

本章将讨论以下主题:

  • 单元测试概述

  • Mocha 框架

  • 定义测试用例

  • 验证开发的 API

单元测试概述

单元测试是软件开发中的基本实践,确保单个代码单元按预期工作。在 REST API 的上下文中,这意味着分别测试每个端点和相关的业务逻辑,以验证它们对不同输入和情况是否正确响应。

单元测试只是测试 REST API 的一部分。它补充了其他类型的测试,如集成测试(测试 API 的不同部分如何协同工作)和端到端测试(从用户的角度测试整个应用程序)。

单元测试是软件开发中的关键实践,但它高度特定于你正在工作的代码库。在编写单元测试用例时,重要的是要涵盖广泛的情况,以确保你的代码正确运行。以下是在编写单元测试时需要考虑的一些关键点:

  • 测试用例结构:

    使用**describe**块描述测试用例正在测试的内容。使用**it**块创建单个测试用例。逻辑地构建你的测试,涵盖代码的各个方面。

  • 测试数据:

    包含覆盖不同输入场景的测试数据或模拟数据。包括边缘情况、边界值和典型输入,以验证你的代码在不同情况下的行为。

  • 断言:

    使用你的测试框架提供的断言(例如,Chai、Jest、Jasmine)来检查代码是否产生预期的结果。验证实际结果是否与预期结果相符。

  • 错误处理:

    确保代码正确处理错误或异常。测试预期抛出异常或错误的场景。

  • 代码覆盖率:

    争取良好的代码覆盖率,确保尽可能多的代码通过测试被执行。使用代码覆盖率工具来识别未测试的代码路径。

  • 模拟和存根:

    使用模拟和存根来模拟外部依赖,如数据库、API 或服务。确保代码与这些依赖正确交互。

  • 积极和消极测试:

    使用一切按预期工作的积极场景进行测试。使用可能出错且代码能够正确处理错误的消极场景进行测试。

  • 回归测试:

    定期运行单元测试,以捕捉引入新代码更改时的回归。

  • 自动化

    单元测试是自动化的,这意味着它们可以由测试框架自动运行,无需人工干预。

  • 快速执行

    单元测试旨在快速执行,以便在开发过程中频繁运行。这个快速反馈循环有助于在开发早期阶段捕捉问题。

记住,单元测试应专注于单个代码单元(一个函数、方法或一个小组件),并且应该快速执行。编写全面的单元测试有助于在开发早期阶段识别和解决问题,从而产生更健壮和可维护的代码。

端到端测试另一方面评估整个应用程序从开始到结束。它模拟真实用户场景。它本质上测试用户如何与应用程序交互,包括真实数据库连接、网络连接等。如果需要连接其他应用程序,它也会连接。

在本章中,为了测试功能,我们将连接到真实数据库以获取数据。当我们使用真实数据库连接进行测试时,这理想上应该是单元测试,我们转向了更集成的测试方法。有时,这可能会被称为集成测试。

Mocha 框架

Mocha 是一个流行的 Node.js 和网页浏览器的 JavaScript 测试框架。它为编写和运行 JavaScript 应用程序的测试用例提供了一个灵活且功能丰富的环境。Mocha 通常与 Chai 等断言库结合使用,以便在测试用例中进行断言。

Mocha 因其灵活性和在开发者中的广泛采用而受到好评,原因有很多:

  • 易用性:

    Mocha 的语法易于学习和编写,这使得它既适合初学者也适合经验丰富的开发者。

  • 支持各种测试风格:

    Mocha 支持不同的测试风格,如 BDD(行为驱动开发)、TDD(测试驱动开发)和 QUnit。

  • 异步测试:

    Mocha 内置了对测试异步代码的支持,允许您使用 **回调****承诺****async**/**await**

  • 钩子:

    Mocha 提供了 **before****after****beforeEach****afterEach** 等钩子来设置和清理测试固定装置。

  • 报告系统:

    Mocha 提供了多种内置的报告器,用于以不同格式生成测试报告和结果,以及自定义报告器的支持。

  • 并行测试执行:

    Mocha 可以并行运行测试,这可以显著减少大型测试套件的测试执行时间。

  • 测试套件和嵌套描述:

    您可以将测试组织成层次套件和描述块,以获得更好的结构和可读性。

  • 超时:

    Mocha 允许您为单个测试或测试套件设置超时限制,有助于识别缓慢或阻塞的测试。

  • 测试跳过和独占性:

    您可以使用 **.skip****.only** 跳过或专注于特定的测试或测试套件。

  • 浏览器和 Node.js 支持:

    Mocha 既可以用于 Node.js,也可以用于网页浏览器。

Mocha 的灵活性、广泛的生态系统和活跃的社区使其成为测试 JavaScript 应用程序的流行选择,从小型库到大型复杂项目。

要使用 Mocha,您通常将其作为 npm 包安装,并用 JavaScript 或 Chai 等测试框架编写测试用例。Mocha 提供了命令行界面来运行测试,并且它可以集成到持续集成 (CI) 管道中来自动化测试。

安装 Mocha 和 Chai

Mocha 是一个测试框架,Chai 是一个断言库,通常一起用于测试。通过在项目根目录下使用 **cmd** 命令,将它们作为开发依赖项安装到我们的项目中

**$ npm install mocha chai @types/mocha @types/chai --save-dev**

在您成功安装 Chai 之后,让我们来探索如何利用它。Chai 是一个多功能的断言库,有效地作为您测试需求的插件。**Chai** 提供了三种主要风格:**"expect****"should****"assert**。您可以使用其中任何一种,但 **"expect"** 是最受欢迎的选择。

const chai = require('chai');

const expect = chai.expect;

// 预期一个值等于另一个值

expect(5).to.equal(5);

// 预期一个数组包含特定元素

expect([1, 2, 3]).to.include(2);

// 预期一个值是特定数据类型

expect('Hello').to.be.a('string');

// 预期一个对象具有属性

expect({ name: 'John' }).to.have.property('name');

您可以将各种方法链式调用以创建复杂的断言。**Chai** 提供了广泛的断言方法来检查相等性、检查属性的存在等。Chai 通常与 Mocha 等测试框架一起使用。

让我们在项目的 **src** 目录下创建一个名为 **tests** 的目录,并在其中创建一个 **test.spec.ts** 文件。测试文件的扩展名必须是 **.spec.ts**,这样工具才能将其识别为测试文件(也称为规范文件)。根据以下目录结构,您可以创建测试文件。

**tests/**

├── user/

│   ├── user.spec.ts

├── common/

│   ├── project.spec.ts

├── project/

│   ├── user.spec.ts

├── common/

│   ├── utility.spec.ts

└── mocha.opts

您可以创建一个 Mocha 配置文件 (**mocha.opts**),如果您想指定 Mocha 选项。此文件是可选的,但它可以方便地配置 Mocha 的行为。以下是一个简单的示例:

--require ts-node/register

--require chai/register-assert

--require chai/register-expect

在添加配置后,让我们在 **utility.spec.ts** 文件中定义第一个基本测试用例,代码如下:

--require chai-http/register

**tsconfig.json** 文件中,在 **compilerOptions** 下添加 **"types": ["express", "./src/custom.d.ts"]**

**package.json** 文件中添加以下测试脚本:

"scripts": {

"test": "mocha --require ts-node/register src/**/*.spec.ts

src/**/**/*.spec.ts

",

您可以创建一个 Mocha 配置文件 (**mocha.opts**),如果您想指定 Mocha 选项。此文件是可选的,但它可以方便地配置 Mocha 的行为。以下是一个简单的示例:

目前没有定义测试用例,所以将显示 0 个通过测试。

图 9.1: 运行测试脚本

定义测试用例

在添加配置后,让我们在 **utility.spec.ts** 文件中定义第一个基本测试用例,代码如下:

import chai from 'chai';

import chaiHttp from 'chai-http';

chai.use(chaiHttp);import { describe, it } from 'mocha';

// 使用 Chai 和 Chai HTTP

const expect = chai.expect;

describe('Array', function () {

describe('#indexOf()', function () {

it('should return -1 when the value is not present', function () {

expect([1, 2, 3].indexOf(4)).to.equal(-1);

});

});

});

此测试用例检查 **indexOf** 函数是否在数组中不存在值时正确返回 **-1**。Chai expect 函数用于在测试用例中进行清晰和可读的断言。如果满足预期,测试将通过;否则,它将失败,并提供有关出错原因的反馈。

**cmd** 中运行测试,输入 **$ npm run test**,将得到以下输出:

**数组**

**1) #indexOf()**

**✔当值不存在时应返回 -1**

图 9.2: 工具成功测试用例

如果您将 expect 行更改为 **expect([1, 2, 3].indexOf(3)).to.equal(-1)**,则测试用例将失败,并显示以下带有红色标记的输出:

**数组**

**1) #indexOf()**

**当值不存在时应返回 -1**

图 9.3: 工具失败测试用例

src/**/**/*.spec.ts

配置应用程序

我们需要在**express_server.ts**文件中做一些修改。由于我们想在测试用例中连接**express**应用,我们需要将其导出。以下是更新后的代码:

import express from 'express';

import * as bodyParser from 'body-parser';

import { IServerConfig } from './utils/config';

import * as config from '../server_config.json';

import { Routes } from './routes';

export class ExpressServer {

private static server = null;

public server_config: IServerConfig = config;

**public app;**

constructor() {

const port = this.server_config.port ?? 3000;

//初始化 express 应用

**this.app = express();**

this.app.use(bodyParser.urlencoded({ extended: false }));

this.app.use(bodyParser.json());

this.app.get('/ping', (req, res) => {

res.send('pong');

});

const routes = new Routes(this.app);

if (routes) {

console.log('服务器路由已启动');

}

ExpressServer.server = this.app.listen(port, () => {

console.log(`服务器正在端口 ${port} 上运行,进程 ID = ${process.pid}`);

});

}

//在未捕获异常时安全关闭 express 服务器

public closeServer(): void {

ExpressServer.server.close(() => {

console.log('服务器已关闭');

process.exit(0);

});

}

}

同样,在处理测试用例时,需要连接到数据库。将**db.ts**文件中的代码替换为以下内容:

import { DataSource, Repository } from 'typeorm';

import { IServerConfig } from './config';

import * as config from '../../server_config.json';

import { Roles } from '../components/roles/roles_entity';

import { Users } from '../components/users/users_entity';

import { Projects } from '../components/projects/projects_entity';

import { Tasks } from '../components/tasks/tasks_entity';

import { Comments } from '../components/comments/comments_entity';

export class DatabaseUtil {

private server_config: IServerConfig = config;

private static connection: DataSource | null = null;

private repositories: Record<string, Repository<any>> = {};

constructor() {

this.connectDatabase();

}

/**

* 建立数据库连接或返回可用的现有连接。

* @returns 返回数据库连接实例。

*/

public async connectDatabase(): **Promise<DataSource>** {

try {

**if (DatabaseUtil.connection) {**

**return Promise.resolve(DatabaseUtil.connection);**

**}** else {

const db_config = this.server_config.db_config;

const AppSource = new DataSource({

type: 'postgres',

host: db_config.host,

port: db_config.port,

username: db_config.username,

password: db_config.password,

database: db_config.dbname,

entities: [Roles, Users, Projects, Tasks, Comments,Files],

synchronize: true,

logging: true,

poolSize: 10

});

await AppSource.initialize();

DatabaseUtil.connection = AppSource;

console.log('已连接到数据库');

return DatabaseUtil.connection;

}

} catch (error) {

console.error('Error connecting to the database:', error);

}

}

/**

* 获取给定实体的存储库。

* @param entity - 需要存储库的实体。

* @returns 实体的存储库实例。

*/

public getRepository(entity) {

try {

// 检查是否有有效的数据库连接

if (DatabaseUtil.connection) {

const entityName = entity.name;

// 检查存储库实例是否已存在,如果不存在,则创建它

if (!this.repositories[entityName]) {

this.repositories[entityName] = DatabaseUtil.connection.getRepository(entity);

}

return this.repositories[entityName];

}

return null;

} catch (error) {

console.error(`Error while getRepository => ${error.message}`);

}

}

}

如代码所示,我们引入了一个**promise**来确保测试用例仅在数据库连接建立后运行。这有助于防止可能发生的潜在错误。

如果我们想要模拟数据库连接,有可用的库来完成这项工作。其中一个这样的库是**sinon****https://sinonjs.org/**

**Sinon**是一个用于在 JavaScript 测试中创建间谍、存根和模拟的测试库,而不是专门用于模拟数据库。它可以用来拦截和模拟函数、方法或代码库中的任何类型的操作,包括数据库操作、API 请求或任何其他外部服务交互。这使得它在编写需要隔离被测试代码部分的单元和集成测试时非常有用。

对于本章,我们感兴趣的是检查我们的 API 与真实数据库的兼容性。因此,我们不需要使用模拟数据库连接。然而,为了完整性,本章末尾添加了一个示例。

现在,将以下代码替换为**utility.spec.ts**文件中的导出应用在测试用例中:

import { DatabaseUtil } from '../../utils/db';

import { ExpressServer } from '../../express_server';

import chai from 'chai';

import chaiHttp from 'chai-http';

chai.use(chaiHttp);

import { describe, it } from 'mocha';

// 使用 Chai 与 Chai HTTP

const expect = chai.expect;

let app, expressServer;

**before(async () => {**

const databaseUtil = new DatabaseUtil();

await databaseUtil.connectDatabase();

expressServer = new ExpressServer();

app = expressServer.app;

**});**

// 在所有测试完成后关闭服务器

**after(function (done) {**

expressServer.closeServer(done);

**});**

export { app };

它导入必要的模块,如**DatabaseUtil****ExpressServer**以及测试库**(chai and chai-http)**chai.use(chaiHttp)配置 chai 以与 HTTP 请求一起工作,使您能够发出 HTTP 请求并对它们的响应进行断言。

钩子

在 Mocha 等测试框架的上下文中,**"before"****"after"** 被称为测试钩子。它们用于设置和清理测试环境。以下是它们的作用:

钩子之前 (**before**): 这个钩子在测试套件(使用 describe 定义)中的任何测试用例运行之前执行。它通常用于设置环境或测试所需的任何公共上下文。例如,你可能用它来建立数据库连接、初始化变量或启动服务器。

**before(() => {**

// 设置测试环境

});

钩子之后 (**after**): 这个钩子在测试套件中的所有测试用例运行之后执行。它通常用于清理环境、释放资源或执行测试完成后必要的操作。例如,你可能用它来关闭数据库连接、关闭服务器或执行 **清理** 任务。

**after(() => {**

// 清理测试环境

});

这里是如何将 **"before"****"after"** 钩子融入到测试生命周期中的:

所有测试之前**"before"** 钩子在套件中的任何测试用例运行之前执行。它是整个套件的单一设置。

运行测试用例:套件中所有的测试用例(it 块)都会被执行。

所有测试之后**"after"** 钩子在一次所有测试用例在套件中完成后执行。它是套件的单一 **清理** 步骤。

这些钩子对于确保每个测试套件都有一个一致且干净的测试环境非常有用。它们有助于避免代码重复,并使管理数据库连接、服务器或其他设置和清理任务变得更容易。

也有 **beforeEach()****afterEach()** 钩子,它们分别在每个测试用例之前和之后执行。

我们可以通过另一个示例来探索钩子,例如创建一个 **payment.spec.ts** 文件,包含以下代码:

import { expect } from 'chai';

import { describe, it } from 'mocha';

class Payment {

private amount: number;

private method: string;

constructor(amount: number, method: string) {

this.amount = amount;

this.method = method;

}

processPayment(): string {

// 模拟支付处理

return `Payment of ${this.amount} processed via ${this.method}`;

}

}

describe('Payment', () => {

let payment: Payment;

// 在钩子之前:这将运行在测试套件之前 before(() => {

console.log('正在设置支付处理…');

// 执行设置任务,例如,初始化支付网关

payment = new Payment(100, 'Credit Card');

});

// 钩子之后:这将运行在测试套件之后 after(() => {

console.log('正在拆除支付处理…');

// 执行清理任务,例如,关闭支付网关连接

payment = null!;

});

// 测试用例

it('should process payment successfully', () => {

// 行动

const result = payment.processPayment();

// 断言

expect(result).to.equal('通过信用卡处理了 100 元的支付');

});

});

在这个例子中:

  • 我们有一个具有 **processPayment** 方法的 Payment 类,该方法模拟处理支付交易。

  • 我们使用 Mocha 的 before 钩子在测试套件之前执行设置任务。这包括初始化支付处理,例如设置支付网关。

  • 我们使用 Mocha 的 after 钩子在测试套件之后执行清理任务。这包括关闭支付网关连接。

  • 我们定义了一个单独的测试用例来验证支付是否成功处理。

  • 在测试套件运行之前,将记录 **"Setting up payment processing…"** 消息,表示正在设置支付处理。

  • 在测试套件运行后,将记录 **"Tearing down payment processing…"** 消息,表示正在拆解支付处理。

现在在终端中运行测试脚本,它将显示以下输出:

图 9.4:带有支付的钩子示例

通过测试用例验证 API

在软件开发的世界里,确保你的应用程序的 API 按预期工作至关重要。为了实现这一点,我们依赖于测试用例,这是一种结构化的方法,用于验证 API 的功能、正确性和性能。通过测试用例验证 API 涉及系统地测试 API 的各个方面,以确保其按预期行为。

登录测试

**"tests"** 目录下,特别是 **"user"** 子目录中创建一个 **"user.spec.ts"** 文件,并包含以下代码:

import chai from 'chai';

import chaiHttp from 'chai-http';

chai.use(chaiHttp);

import { describe, it } from 'mocha';

// 使用 Chai 和 Chai HTTP

const expect = chai.expect;

import { app } from '../common/utility.spec';

let authToken; // 声明一个变量来存储认证令牌

describe('Login API', () => {

it('should return a success message when login is successful', (done) => {

**chai.request(app)** // 将 'app' 替换为你的 Express 应用实例

**.post(**'**/api/login**'**)**

**.send({ email:** '**yamipanchal1993@gmail.com**'**, password:** '**Abc@123456**' **})**

.end((err, res) => {

**expect(res).to.have.status(200);**

expect(res.body).to.have.property('status').equal('success');

authToken = res.body.data.accessToken; // 保存认证令牌

done();

});

});

it('should return an error message when login fails', (done) => {

chai.request(app)

.post('/api/login')

**.send({ email:** '**yamipanchal1993@gmail.com**'**, password:** '**wrongpassword**' **})**

.end((err, res) => {

**expect(res).to.have.status(400);**

**expect(res.body).to.have.property('message').equal('密码无效');**

done();

});

});

});

export { authToken };

通过 **cmd** 通过 **npm run** test 运行测试用例,将提供以下输出。

> pms-be@1.0.0 test

> mocha --require ts-node/register 'src/**/*.spec.ts'

登录 API

1) "**before all**" hook in "**{root}**"

连接到数据库

**✔> should return a success message when login is successful (75ms)**

**✔ should return an error message when login fails (46ms)**

图 9.5:登录测试用例

在这里,我们为登录 API 定义了两个测试用例,第一个有有效数据,第二个有错误的密码。

  • **describe('Login API', () => { … });**: 这行代码使用 describe 函数定义了一个测试套件。在这种情况下,它是一个名为 **"Login API"** 的套件,将相关的测试用例组合在一起。

  • **it('should return a success message when login is successful', (done) => { … });**: 在测试套件中,使用 it 函数定义了一个单独的测试用例。这个测试用例有一个描述,解释了它要测试的内容,即当登录成功时应该返回一个成功消息。**(done)** 函数作为参数传递,表示这是一个异步测试,done 函数用于表示测试完成。

  • **chai.request(app)**: 这行代码使用 chai-http 库向 Express.js 应用程序发送 HTTP 请求。app 应替换为你的 Express 应用的实际实例。**.post('/api/login')**: 这行代码指定了一个 POST 请求到 **'/api/login'** 端点。这个端点可能负责处理用户登录。

  • **.send({ email: 'yamipanchal1993@gmail.com', password: 'Abc@123' })**: 这里,代码在请求体中发送一个包含电子邮件和密码值的 JSON 对象,用于登录尝试。

  • **.end((err, res) => { … });**: 这是在 HTTP 请求完成后执行的 **回调函数**。它接收两个参数:err 用于请求过程中可能发生的任何错误,res 用于服务器的响应。

  • **expect(res).to.have.status(200);**: 这行代码使用 Chai 的 expect 断言检查 HTTP 响应是否有状态码 200,这通常表示请求成功。

  • **expect(res.body).to.have.property('status').equal('success');**: 这行代码检查响应体是否包含名为 'status' 的属性,其值为 **'success'**。这是检查 API 响应是否表示成功操作的一种常见方式。

  • **authToken = res.body.data.accessToken;**: 如果登录成功,这行代码从响应中提取认证令牌并将其存储在 authToken 变量中。此令牌通常用于后续的认证请求。

  • **done();**: 最后,调用 **done** 函数来表示测试已完成。

这段代码是登录 API 返回预期状态码和消息的成功响应的测试用例。它还捕获了认证令牌,通常用于后续的认证请求。

用户测试列表

在同一文件中,添加以下测试用例代码以验证用户列表 API。

describe('获取用户列表', () => {

it('应当返回状态码为 200 的数组', (done) => {

chai.request(app)

.get('/api/users')

.set('Authorization',

`Bearer ${authToken}`) // 在头部传递 token

.end((err, res) => {

// console.log(res);

expect(res).to.have.status(200);

expect(res.body).to.have.property('data').to.be.an('array');

done();

});

});

});

输出

> pms-be@1.0.0 test

> mocha --require ts-node/register 'src/**/*.spec.ts'

登录 API

1) "**before all**" 钩子在 "**{root}**"

连接到数据库

**✔** 应当在登录成功时返回成功消息 (75ms)

**✔** 应当在登录失败时返回错误消息 (46ms)

**获取用户列表**

**✔ 应当返回状态码为 200 的数组**

服务器关闭

图片 9.6

图 9.6:用户列表获取 API 测试用例

这里是前面代码中关键部分的详细信息:

  • **describe('获取用户列表', () => { … });**: 这一行定义了一个新的测试套件,描述为 **"获取用户列表"**。这个套件将相关的测试用例组合在一起,这些测试用例涉及获取用户列表。

  • **it('应当返回状态码为 200 的数组', (done) => { … });**: 在测试套件中,使用 it 函数定义了一个单独的测试用例。测试用例描述指定它应返回一个状态码为 200 的数组。(**done**) 函数用于指示这是一个异步测试,当测试完成时将调用 done 函数。

  • **.get('/api/users')**: 这一行指定了对 **'/api/users'** 端点的 GET 请求。这个端点可能负责获取用户列表。

  • **.set('Authorization', Bearer ${authToken}):** 这里,代码正在设置 HTTP 请求中的 **"Authorization"** 头部。它将身份验证令牌包含在头部,通常格式为 **"Bearer <token>"**。这是验证 API 请求的常用方法。**authToken** 预期将包含在成功登录期间获得的令牌(如你之前的代码片段所示)。

  • **.end((err, res) => { … });**: 这是当 HTTP 请求完成时执行的 **回调函数**。它接收两个参数:**err** 用于可能发生在请求期间的错误,**res** 用于来自服务器的响应。

  • **expect(res).to.have.status(200);**: 这一行使用 Chai 的 expect 断言来检查 HTTP 响应是否有状态码 200,这通常表示请求成功。这是确保服务器对成功的 **GET** 请求返回 200 **OK** 状态的常用方法。

  • **expect(res.body).to.have.property('data').to.be.an('array');**: 这行代码检查响应体是否包含名为 **'data** 的属性,并且该属性的值是一个数组。这是验证响应包含以数组形式呈现的用户列表的一种方式。

  • **done();**: 最后,调用 **done** 函数来表示测试已完成。

这段代码是一个 Express.js API 端点测试用例,用于测试获取用户列表的 GET 请求是否返回预期的状态码 (**200**),并验证响应包含用户数据数组。它还在请求头中包含授权令牌进行身份验证,假设 **authToken** 变量包含从登录请求中获取的有效令牌。

添加用户测试

在同一文件中,添加以下测试用例以验证添加用户 API。

describe('添加用户', () => {

it('should return with status code 201', (done) => {

chai.request(app)

.post('/api/users')

.set('Authorization',

`Bearer ${authToken}`) // 在头部传递令牌

.send({

'fullname': 'Super Admin',

'username': 'pms-admin1',

'email': 'admin@pms1.com',

'password': 'Admin@pms1',

'role_id': 'dbda47e4-f843-4263-a4d6-69ef80156f81'

})

.end((err, res) => {

**expect(res).to.have.status(201);**

done();

});

});

it('should return with status code 409', (done) => {

chai.request(app)

.post('/api/users')

.set('Authorization',

`Bearer ${authToken}`) // 在头部传递令牌

.send({

'fullname': 'Super Admin',

'username': 'pms-admin1',

'email': 'admin@pms1.com',

'password': 'Admin@pms1',

'role_id': 'dbda47e4-f843-4263-a4d6-69ef80156f81'

})

.end((err, res) => {

**expect(res).to.have.status(409);**

**expect(res.body).to.have.property('message').equal('键(username)=(pms-admin1)已存在.');**

done();

});

});

});

输出

> pms-be@1.0.0 测试

> mocha --require ts-node/register 'src/**/*.spec.ts'

登录 API

1) "before all" 钩子在 "{root}"

连接到数据库

**✔** 应在登录成功时返回成功消息(75ms)

**✔** 应在登录失败时返回错误消息(46ms)

获取用户列表

**✔** 应返回状态码 200 的数组

**添加用户**

**✔ 应返回状态码 201**

**✔ 应返回状态码 409(54ms)**

服务器关闭

图 9.7:添加用户 API 测试用例

这里是前面代码关键部分的详细信息:

  • **describe('添加用户', () => { … });**: 这行代码定义了一个测试套件,其描述为 **"添加用户"**。该套件将相关的测试用例组合在一起,这些测试用例涉及添加新用户。

  • 第一个测试用例:

    • **it('should return with status code 201', (done) => { … });**: 这个测试用例描述表明,它正在测试添加用户是否应该返回状态码 **201**(已创建)。(done) 函数用于指示这是一个异步测试,当测试完成时将调用 done` 函数。

    • **.post('/api/users')**: 这是一个 **POST** 请求到 **'/api/users'** 端点,可能是用于添加新用户。

    • **.set('Authorization', Bearer ${authToken})**: 这行代码在 HTTP 请求中设置了一个 **"Authorization"** 标头,包括用于授权的认证令牌。

    • **.send({ … })**: 这段代码在请求体中发送一个包含用户信息的 JSON 对象,包括用户的姓名、用户名、电子邮件、密码和角色 ID。这代表您尝试添加的数据。

    • **.end((err, res) => { … });**: 当 HTTP 请求完成时执行的 **回调** 函数。

    • **expect(res).to.have.status(201);**: 这行代码使用 Chai 的 expect 断言来检查 HTTP 响应是否具有状态码 201,表示用户创建成功。

    • **done();**: 最后,调用 **done** 函数来表示测试已完成。

  • 第二个测试用例:

    • **it('should return with status code 409', (done) => { … });**: 这个测试用例描述表明,正在测试添加具有相同用户名的用户是否应该返回状态码 **409**(冲突)。

    • 这个测试用例的结构与第一个类似,主要区别在于预期的状态码和额外的检查:

    • **expect(res).to.have.status(409);**: 这行代码检查 HTTP 响应是否具有状态码 **409**,表示冲突。

    • **expect(res.body).to.have.property('message').equal('Key (username)=(pms-admin1) already exists.');**: 这行代码验证响应体包含一个特定的消息,表明提供的用户名已存在。

    • **done();**: 如前所述,调用 **done** 函数来表示测试已完成。

这段代码包含两个测试用例。第一个测试用例检查是否成功添加了新用户并返回 201 状态码,而第二个测试用例检查在尝试添加具有现有用户名的用户时是否返回冲突(409 状态码),第二个测试用例还验证了响应中是否存在错误消息。

删除用户测试

在现有文件中,包含以下测试用例代码以验证用户删除 API 的功能。

describe('Delete User', () => {

it('should return with status code 200', (done) => {

chai.request(app)

.delete('/api/users/0ddc59fe-a9ea-4060-9b39-5118fe13937d')

`.set('Authorization',

`Bearer ${authToken}`) // 在头部传递令牌

.end((err, res) => {

expect(res).to.have.status(201);

done();

});

});

it('should return with status code 404', (done) => {

chai.request(app)

.delete('/api/users/0ddc59fe-a9ea-4060-9b39-5118fe13937d')

.set('Authorization', `Bearer ${authToken}`) // 在头部传递令牌

.end((err, res) => {

expect(res).to.have.status(404);

done();

});

});

});

输出

> pms-be@1.0.0 test

> mocha --require ts-node/register 'src/**/*.spec.ts'

登录 API

1) "{root}" 中的 "before all" 钩子

连接到数据库

**✔** 应在登录成功时返回成功消息(75ms)

**✔** 应在登录失败时返回错误消息(46ms)

获取用户列表

**✔** 应返回包含状态码 200 的数组

添加用户

X 应返回包含状态码 201 的数组

**✔** 应在 54ms 内返回状态码 409

**删除用户**

**✔ should return with status code 200**

**✔ should return with status code 404**

服务器已关闭

图 9.8:删除用户 API 测试用例

在此代码中,添加用户测试用例显示失败,因为同一用户已在数据库中。同样,如果测试运行时您尝试删除的用户不存在于数据库中,删除用户测试用例可能会失败。

  • 第一个测试用例:

    • **it('should return with status code 200', (done) => { … });**: 此测试用例的描述表明,它正在测试成功删除用户是否应返回状态码 **200** (**OK**)。(done) 函数用于指示这是一个异步测试,当测试完成时将调用 done 函数。

    • **.delete('/api/users/0ddc59fe-a9ea-4060-9b39-5118fe13937d')**: 这是一个针对特定端点 **'**/**api/users/'** 的 DELETE 请求,其中包含用户标识符(例如,**'0ddc59fe-a9ea-4060-9b39-5118fe13937d'**)在 URL 中。这通常表示通过其唯一标识符删除特定用户的行为。

    • **.set('Authorization', Bearer ${authToken})**: 这行代码在 HTTP 请求中设置了一个 **"Authorization"** 标头,包括用于授权的认证令牌。

    • **.end((err, res) => { … });**: 当 HTTP 请求完成时执行的 **callback** 函数。

    • **expect(res).to.have.status(201);**: 这行代码中可能存在潜在问题。它检查 HTTP 响应是否具有状态码 **201**,但测试用例的描述暗示它应该期望状态码 **200**。这行代码应更正为 **expect(res).to.have.status(200)**;。

    • **done();**: 最后,调用 **done** 函数以表示测试已完成。

  • 第二个测试用例:

    • **it('should return with status code 404', (done) => { … });**: 此测试用例的描述表明,它正在测试尝试删除不存在的用户是否应返回状态码 **404 (Not Found)**。此测试用例的结构与第一个类似,主要区别在于预期的状态码:

    • **expect(res).to.have.status(404);**: 这行代码检查 HTTP 响应是否具有 404 状态码,表示请求的用户未找到。

    • **done();**: 如前所述,调用 done 函数以表示测试完成。

这段代码包含两个测试用例,用于通过 Express.js API 测试用户删除功能。第一个测试用例检查是否成功删除了用户并返回了 **200** 状态码,第二个测试用例检查尝试删除不存在用户是否会导致返回 **404** 状态码。

以这种方式,您可以根据应用程序的不同部分创建不同的测试场景,包括用户管理、项目管理、任务管理和评论功能。对于这些 API 中的每一个,您都可以定义特定的测试用例。此外,您可以通过包含验证身份验证失败的案例来扩展您的测试套件,例如,当认证缺失时测试 **401** 状态码,当某些 API 端点未授权时测试 403 状态码。

模拟数据库连接

为了模拟数据库连接,我们可以使用流行的库——**sinon**。让我们使用 npm 安装这个库。

npm install sinon –save-dev

考虑如果我们有一个根据给定 ID 获取用户的函数:

// db.ts

async function getUserByUserId(user_id:string) {

// 从数据库获取用户的实际逻辑

}

module.exports = { getUserByUserId };

我们可以使用 sinon 来模拟这种行为 -

const sinon = require('sinon');

const { expect } = require('chai');

const db = require('../db');

describe('getUserByUserId', function() {

it('should return mocked user data', async function() {

// 为 getUserByUserId 创建存根

const mockUser = { id: 1, name: 'Alice M' };

const stub = sinon.stub(db, 'getUserByUserId').resolves(mockUser);

// 调用函数(现在已被存根化)

const user = await getUserByUserId(1);

// 验证函数返回了模拟数据

expect(user).to.deep.equal(mockUser);

// 恢复原始函数

stub.restore();

});

});

在前面的示例中,**sinon.stub()** 用于用返回一个解析为 **mockUser** 的 promise 的版本替换实际的 **getUserByUserId()** 函数。这个测试没有连接到实际的数据库。这样它确保了测试是隔离和可重复的。**stub.restore()** 在最后恢复原始函数。

结论

在本章中,代码示例中展示的测试用例提供了一个全面的方法来测试 Express.js API 应用程序的各个方面。这些测试用例涵盖了不同的场景,包括登录和用户管理 API。这些测试是确保 API 的可靠性、安全性和正确性的关键部分,并且它们可以帮助在开发早期阶段识别和解决问题。

通过系统地测试应用程序,您可以提高其鲁棒性并提升您软件的整体质量。

在下一章中,我们将学习如何构建和部署我们的应用程序。

第十章

构建和部署应用程序

简介

在完成开发阶段后,使应用程序能够实现全局访问变得至关重要。任何应用程序的最终和关键步骤是将它构建并部署到一个集中位置,确保最终用户能够广泛地获得和使用。当部署 Node.js 应用程序时,有各种服务器和平台可供托管和运行您的应用程序。选择取决于因素,如可扩展性、易用性、性能和项目的特定要求。

在本章中,我们将深入探讨构建和安全的部署应用程序,采用最广泛使用的部署流程。

结构

在本章中,我们将讨论以下主题:

  • 代码混淆

  • 构建应用程序

  • 部署应用程序

代码混淆

代码混淆是一种将源代码转换为更难以理解或逆向工程的形式的技术,同时保持其原始功能。代码混淆的目的不是显著提高代码的安全性,而是使代码更难以理解或反编译。

常见技术

代码混淆中常用的一些技术如下:

  • 重命名变量和函数:

    混淆器将变量、函数和类的名称更改为无意义或随机的字符串,使得理解每个元素的目的变得更加困难。

  • 控制流混淆:

    这涉及到重构程序的流程控制,例如使用**goto**语句或引入冗余代码,使其更不可预测且难以跟踪。

  • 字符串加密:

    代码中的文本字符串被加密或编码,然后在运行时解密或解码。这使得仅通过检查代码来理解字符串值变得更加困难。

  • 代码拆分:

    将函数分解成更小的部分或将它们拆分到多个文件中,使得理解整个程序的流程变得更加困难。

  • 虚拟代码插入:

    引入无关或冗余的代码片段,这些代码片段不影响程序的功能,但会增加试图理解代码的人类读者的复杂性。

  • 常量值混淆:

    通过改变数值常量的表示或使用数学运算来掩盖其真实值。

  • 反调试技术:

    嵌入检测调试尝试的代码并改变程序行为的代码,使得逆向工程师在调试器中分析代码变得更加困难。

  • 代码压缩:

    通过压缩代码来减少整体大小,使其更难以阅读和分析。

让我们深入了解代码混淆的实际方面,并亲手探索其细节。

对整个 TypeScript 项目进行混淆涉及将代码混淆应用于所有 TypeScript 文件,包括它们的依赖项。这个过程可能有些复杂,需要仔细考虑构建过程、依赖项以及可能对项目产生的影响。

代码可以通过多种方式进行混淆。所有工具中最受欢迎的是 **javascript-obfuscator****UglifyJs****webpack** 以及其他工具。我们将使用 **javascript-obfuscator** 来混淆代码。

安装所需依赖

安装 JavaScript 混淆库 **javascript-obfuscator** 以将其集成到项目中。从项目的根目录打开终端并粘贴以下命令:

$ npm install  javascript-obfuscator  --save-dev

创建混淆脚本

在根目录下创建一个工具目录,并添加名为 **obfuscate.js** 的脚本文件,其中包含以下代码,该代码将混淆每个生成的 JavaScript 文件。

// obfuscate.js

/* eslint-disable no-undef */

/* eslint-disable @typescript-eslint/no-var-requires */

const JavaScriptObfuscator = require('javascript-obfuscator');

const fs = require('fs');

const path = require('path');

const jsonObfuscatorModule = require('json-obfuscator');

const **sourceDirectory** = 'dist/src'; // 使用您的实际输出目录更新

const **obfuscatedDirectory** = 'build'; // 混淆代码的输出目录

const obfuscateFile = (filePath) => {

const code = fs.readFileSync(filePath, 'utf8');

const obfuscatedCode = JavaScriptObfuscator.obfuscate(code, {

compact: true,

controlFlowFlattening: true

// …其他混淆选项

});

const obfuscatedFilePath = path.join(obfuscatedDirectory, path.relative(sourceDirectory, filePath));

fs.mkdirSync(path.dirname(obfuscatedFilePath), { recursive: true });

fs.writeFileSync(obfuscatedFilePath, obfuscatedCode.getObfuscatedCode(), 'utf8');

};

const processDirectory = (directoryPath) => {

const files = fs.readdirSync(directoryPath);

files.forEach((file) => {

const filePath = path.join(directoryPath, file);

if (fs.statSync(filePath).isDirectory()) {

processDirectory(filePath);

} else if (path.extname(filePath) === '.js') {

obfuscateFile(filePath);

}

});

};

processDirectory(sourceDirectory);

Here,

  • **sourceDirectory** 指定包含原始 JavaScript 文件的目录。您应该使用从 TypeScript 文件编译生成的 JavaScript 文件的实际输出目录来更新它。

  • **obfuscatedDirectory** 指定混淆文件将被保存的目录。

  • **obfuscateFile(filePath)** 读取 JavaScript 文件的内容,使用 **javascript-obfuscator** 进行混淆,并将混淆后的内容写入指定位置。

  • **processDirectory(directoryPath)** 递归处理目录中的文件,对每个找到的 JavaScript 文件调用 obfuscateFile

因此,当此脚本运行时,将创建一个新的构建目录,其中包含混淆后的 JavaScript 代码。

这提供了混淆过程的摘要,该过程将在后续的代码构建和执行中应用。

代码混淆的缺点

代码混淆使得源代码更难阅读和理解。然而,它也有一些缺点。

代码混淆可以为代码添加额外的复杂层,这可能会影响运行时的性能。由于代码被混淆,它更难阅读,并且在调试时追踪问题源变得具有挑战性。这增加了调试和故障排除的时间。

对于源代码的未来版本,每次发布新代码时都会再次进行混淆。混淆过程向代码中添加额外的字符和结构,导致文件大小增加。

最后要讨论的一点是安全性。混淆可能会给人一种代码安全的错觉。确实,之后的代码更难阅读,但它可以被逆向工程。混淆并不能修复代码中的任何安全漏洞,如果有人逆向工程了混淆后的代码,这些漏洞可能会被利用。

虽然熟练的黑客可能会逆向工程代码,但混淆对普通或不太熟练的黑客起到了威慑作用。即使是熟练的黑客,逆向工程也需要额外的努力。

构建应用程序

构建应用程序是指将应用程序的源代码转换成计算机可以执行或运行的格式或结构的过程。构建过程包括编译和转译。TypeScript 编译是将 TypeScript 源代码转换为 JavaScript 代码的过程,使其与各种 JavaScript 运行时环境兼容。

从根目录打开终端并执行以下命令以编译代码:

$ tsc

此命令根据 **tsconfig.json** 中提供的配置编译应用程序中的所有 TypeScript 文件。它包括具有 **rootDir** 设置为 **src** 的编译器选项,指定输入文件的根目录,以及 **outDir** 设置为 **dist**,指定编译文件的输出目录。编译成功后,TypeScript 将在指定的输出目录 **dist** 中生成等效的 JavaScript 文件。

现在我们更新 **package.json**,如下所示,为 **build****start****test** 添加脚本,以便使用 npm 运行它们。

"scripts": {

"test": "mocha --require ts-node/register 'src/**/*.spec.ts'",

"start": "node dist/src/main.js",

"build": "tsc"

}

**package.json** 中定义脚本后,在终端中使用 npm run 执行它。例如,**npm run build****npm run start****npm run test**。在这里,我们在编译后使用 **js** 运行应用程序,但在开发时可以使用 **tsc –-watch**

在应用程序构建的高级阶段,我们将开发一个脚本来生成应用程序的压缩二进制文件。此文件将封装 JavaScript 源代码并进行必要的 Node 包安装。

在应用程序的根目录中创建包含以下代码的 **mkpackage.sh** 文件。

npm install

./node_modules/.bin/tsc

rm -rf binaries/*

mkdir -p build

mkdir -p binaries

rm -rf build/*

node tools/obfuscate.js

cd build

echo  "Getting git version info.."

export VER=1.0

echo exports.version=\"1.0\" > ver

echo exports.version_long=\"$VER\" >> ver

echo "Copying things.."

cp ../package.json .

echo  "Doing compression .. "

tar czf pms_be_$VER.tgz *.js  package.json components routes tests utils

mv pms_be_$VER.tgz ../binaries/.

cd ..

echo "Created file pms_be_$VER.tgz"

此脚本包含一组用于构建和打包 Node.js 应用程序的命令。让我们逐一分析每个部分:

  • **`npm install`:**

    安装 **package.json** 文件中指定的 Node.js 依赖。

  • **`./node_modules/.bin/tsc`:**

    调用位于 **node_modules** 目录中的 TypeScript 编译器(**tsc**),将 TypeScript 代码转换为 JavaScript。使用 **./node_modules/.bin/** 前缀来运行本地安装的 TypeScript 编译器。

  • **`rm -rf binaries/*`:**

    **binaries** 目录中删除所有文件和子目录。

  • **`mkdir -p build` and `mkdir -p binaries`:**

    如果不存在,则创建 **build****binaries** 目录。

  • **`rm -rf build/*`:**

    清除 **build** 目录中的所有文件和子目录。

  • **`node tools/obfuscate.js`:**

    执行位于 **tools/obfuscate.js** 的 Node.js 脚本。此脚本可能执行 JavaScript 代码的混淆。脚本的具体细节未提供,但似乎是一个用于混淆应用程序 JavaScript 源代码的自定义脚本。

  • **`cd build`:**

    将当前工作目录更改为 **build** 目录。

  • **`echo "Getting git version info.."`:**

    输出一条消息,表明脚本正在检索 Git 版本信息。

  • **`export VER=1.0`:**

    设置一个名为 **VER** 的环境变量,其值为 **1.0**

  • **`echo exports.version=\"1.0\" > ver` and `echo exports.version_long=\"$VER\" >> ver`:**

    创建一个名为 **ver** 的文件,其中包含版本信息。它导出应用程序版本以及一个长版本字符串。

  • **`echo "Copying things.."`:**

    输出一条消息,表明正在复制文件。

  • **`cp ../package.json .`:**

    从父目录(**..**)将 **package.json** 文件复制到当前(**build**)目录。

  • **`echo "Doing compression .. "`:**

    输出一条消息,表明正在压缩。

  • **tar czf pms_be_$VER.tgz *.js package.json components routes tests utils**:

    创建一个包含特定文件和目录(JavaScript 文件、**package.json****components****routes****tests****utils**)的压缩 tarball (```pms_be_$VER.tgz``)。

  • **mv pms_be_$VER.tgz ../binaries/.**😗*

    将创建的 tarball 移动到 **binaries** 目录。

  • **cd ..**😗*

    将工作目录返回到父目录。

  • **echo "Created file pms_be_$VER.tgz"**😗*

    输出一条消息,指示已成功创建 tarball。

此脚本似乎自动化了构建和打包 Node.js 应用程序所涉及的各种任务。它处理依赖项安装、TypeScript 编译、混淆、版本控制和压缩成 tarball 以进行分发。生成的 tarball 存储在 **binaries** 目录中,名称反映了应用程序版本。

此二进制文件将在服务器上进一步部署。

部署应用程序

部署应用程序使用不同类型的服务器,这里我们将使用最推荐的在 AWS 实例上部署 Node.js 应用程序的方式。在亚马逊网络服务 (AWS) 上部署 Node.js 应用程序提供了几个优势,使其成为许多开发人员和企业的首选。AWS 拥有丰富的服务生态系统,这些服务补充了 Node.js 应用程序开发。

AWS 服务器设置

创建一个亚马逊 EC2 实例以托管 Node.js 应用程序涉及几个步骤。以下是如何创建 EC2 实例并在其上部署 Node.js 应用程序的逐步指南。

登录 AWS 管理控制台

亚马逊提供一年的免费层账户,因此任何人都可以创建该账户并使用不同的免费服务。如果您已有该账户,可以直接登录,否则创建账户并使用您的 AWS 账户凭证登录。

一旦登录,请选择一个地理位置上更靠近您的用户的服务区域,以减少延迟并提高您应用程序的响应速度。

图 10.1: 在 AWS 中选择区域

这里,我们选择亚洲太平洋(孟买)地区。

导航到 EC2

在 AWS 管理控制台中,导航到 **"服务"** 下拉菜单。在 **"计算"** 部分,选择 **"EC2"**.**"**

图 10.2: 选择 EC2 实例

选择亚马逊机器镜像 (AMI)

根据您的需求选择一个亚马逊机器镜像 (AMI)。对于基本的 Node.js 应用程序,您可以选择亚马逊 Linux AMI。根据您的应用程序资源需求选择所需的实例类型。默认选项通常适用于小型应用程序。

当您选择 AMI 时,请考虑您可能对要启动的实例有以下要求。

对于一个小型 Node.js 应用程序,通常不需要高性能或昂贵的 EC2 实例。您可以选择一种经济实惠的实例类型,以满足您应用程序的需求。以下是一些适合小型 Node.js 应用程序的 EC2 实例类型:

  • 内存要求: 选择一个为您的 Node.js 应用程序提供足够内存的实例类型。例如,t3.micro 和 t2.micro 实例都配备了 1 GB 的内存。

  • CPU 要求: 对于小型应用程序,像 **t3.micro** 这样的可扩展性能实例可能就足够了。如果您有特定的 CPU 要求,请考虑其他实例类型。

  • 存储: 确定您应用程序所需的存储容量。上述实例类型都配备了弹性块存储 (EBS) 存储,您可以根据需求调整大小。

  • 网络性能: 对于小型应用程序,这些实例的默认网络性能应该足够。如果您预计会有高网络流量,您可能需要考虑更高性能的实例。

    例如:

    • **t3.micro** 这是一个低成本、通用型可扩展实例类型。它适用于工作负载波动且不需要持续高 CPU 性能的应用程序。

    • **t2.micro****t3.micro** 类似,**t2.micro** 也是一个低成本、可扩展的实例类型。这是一个旧一代实例,但它仍然适用于具有轻量级工作负载的小型应用程序。

图 10.3: 选择 AMI

请记住,AWS 提供了免费层,允许您在前 12 个月内免费使用一定数量的资源,包括 **t2.micro** 实例,因此我们选择 **t2.micro**

密钥对生成

您可以使用密钥对安全地连接到您的实例。在启动实例之前,请确保您有权访问所选密钥对。密钥对是一组安全凭证,包括私钥和公钥。

密钥对用于安全访问您的 Amazon EC2 实例。当您启动 EC2 实例时,您指定一个密钥对,公钥放置在实例上,而私钥保持安全。点击 **"创建密钥对****"** 给它一个名称,并下载私钥(. **pem**)文件。

下载完一个 **.pem** 文件后,请在终端中为该文件设置权限。

$ sudo chmod 400 PMS_KEY.pem

使用密钥对 (.pem) 文件对于安全访问您的 EC2 实例至关重要。确保您遵循最佳实践进行密钥管理和安全。如果您丢失了私钥,您可能会失去与该密钥对关联的实例的访问权限。始终安全地存储您的私钥,并避免将其与未经授权的用户共享。

图 10.4: 密钥对生成

网络设置

配置安全组以控制对您的实例的入站和出站流量。

配置规则允许 SSH 访问进行管理,并允许您的 Web 应用程序的 HTTP/HTTPS 访问。始终遵循安全最佳实践,并仅限制对必要的端口和 IP 范围的访问,如下所示。

图 10.5: 网络设置

配置存储

**"添加存储"** 步骤中,您可以配置存储设置。

根卷:这是操作系统安装的根卷。您可以指定大小(以 GiB 为单位)并选择存储类型(例如,通用型 SSD,预配置 IOPS SSD,磁盘)。

添加新卷:如果需要,您可以添加额外的卷。完成后点击 **"下一步"**

图 10.6: 配置存储

在这里,我们选择了 8GB 的存储空间用于 Node.js 应用程序,因此不需要额外的卷。

启动实例

配置实例详情,如实例数量、网络设置和存储。默认设置通常足以满足基本 Node.js 应用程序的需求。

根据您的应用程序数据需求配置存储设置。默认设置通常适用于简单应用程序。检查您的配置设置以确保它们正确。点击 **"启动"** 按钮。

图 10.7: EC2 启动实例

在成功启动实例后,您可以查看状态为运行的状态列表。

图 10.8: 运行中的实例

点击实例 ID 将显示所有详细信息,如公共和私有 IP 地址、主机名、平台详情。如果需要,您还可以编辑实例。

图 10.9: 实例详情

现在,是时候连接那个服务器了,无论是直接从 **aws** 还是像之前描述的那样通过 **ssh**。点击连接按钮以启动连接。

连接服务器

我们已经将.pem 文件保存下来,以便通过 ssh 连接到服务器。打开.pem 文件所在的目录中的终端,并输入以下命令:

$ sudo ssh -i YourKeyName.pem ec2-user@YourPublicIPAddress

**YourKeyName.pem** 替换为您的私钥文件路径,并将 **YourPublicIPAddress** 替换为您的 EC2 实例的公共 IP 地址。

例如,$ sudo ssh -i PMS_KEY.pem ubuntu@65.0.76.190

成功连接到服务器后,你将在终端看到以下输出。

图 10.10: 终端上的已连接服务器

在服务器上部署代码

在成功连接到服务器后,继续安装必要的包,如 Node.js 和 PostgreSQL。调整 **server_config.js** 文件以反映端口、数据库和电子邮件设置的适当配置。

由于我们在这里使用 Linux (Ubuntu),安装 Node.js 的步骤可以从第一章,Node.js 简介中回忆。对于 PostgreSQL,可以按照第四章,应用规划中提到的,遵循官方网站www.postgresql.org/进行安装。

此外,请确保指定的端口,例如**8080**,在 AWS 安全组中已开放,通过配置入站规则以满足我们 Node.js 应用程序的需求。

**pm2**是 Node.js 应用程序的过程管理器,允许您在生产环境中管理和部署 Node.js 应用程序。它提供了各种功能,如进程监控、自动重启和集群,以确保您应用程序的可靠性和性能。

在 EC2 实例上安装**pm2**并在终端运行以下命令。

$npm install -g pm2

让我们在项目的根目录中创建一个**deploy.sh**脚本,其中包含以下代码。此脚本便于将本地服务器上的二进制代码传输到预构建的远程服务器,并执行特定命令以在服务器上启动应用程序。

#!/bin/bash

# 定义 SSH 密钥和其他部署细节

SSH_KEY="../PMS_KEY.pem"

USER="ubuntu"

HOST="43.205.144.240"

PROJECT_DIR="/home/ubuntu/PMS"

HOME_DIR="/home/ubuntu"

CONFIG_FILE="server_config.json"

process_pms_server(){

echo "----"

rm binaries/*

sh mkpackage.sh

echo ""

echo "文件已创建 - "

echo "binaries/"

search_dir=binaries

for entry in "$search_dir"/*.tar.gz

do

echo "- \t$entry"

done

echo ""

echo "==> 正在移除 SensorApp 后端服务器 ... <=="

echo "====================================="

ssh -i "$SSH_KEY" $USER@$HOST "rm $HOME_DIR/pms_be*.tgz ; mkdir -p PMS; chmod +w $PROJECT_DIR;"

echo "==> 正在传输 SensorApp 后端服务器 ... <=="

echo "========================================="

scp -i "$SSH_KEY" binaries/pms_be*.tgz $USER@$HOST:$HOME_DIR/. || exit 1

scp -i "$SSH_KEY" server_config.json $USER@$HOST:$HOME_DIR || exit 1

js `echo "==> Extracting PMS Backend Server. <=="` `echo "======================================"` `` `# Commands to be executed on the remote server` `commands=(` `"tar xf pms_be*.tgz -C $PROJECT_DIR;"` `"cd $PROJECT_DIR;"` `"npm install;"` `"pm2 restart main.js --name pms-api;"` `"pm2 save;"` `)` `ssh -i "$SSH_KEY" $USER@$HOST "${commands[*]}"` `}` `echo "Do you wish to deploy PMS Backend Server?"` `select yn in "Yes"` `"No"; do` `case $yn in` `Yes)` `process_pms_server` `break;;` `No)` `echo "Deployment cancelled."` `break;;` `*)` `echo "Invalid option. Please choose 1 for Yes or 2 for No."` `;;` `esac` `done` `echo ""` `echo ""` `echo "==============================="` `echo "==> The Deployment finished <=="` `echo "==============================="` * `**#!/bin/bash**`: Specifies that the script should be interpreted using Bash. * **Configuration**: Define variables for SSH key, user, host, directories, and configuration file. * **Function** - `**process_sensorapp_backend_server**`: Removes existing binary files. Executes the script (`**mkpackage.sh**`) to create the binary. Displays the created files in the binaries directory. Removes existing server files, creates directories, and sets permissions on the remote server. Transfers binary files and the configuration file to the remote server. Extracts and deploys the PMS Backend Server on the remote server using pm2. * **User Confirmation**: Prompts the user for deployment confirmation using a select statement. * **Completion Message**: Displays a message indicating the completion of the deployment process. This script automates the deployment process of a Node.js application to a remote server using `**pm2**` for process management. Run the script from the terminal with the root directory of the project. `$ ./deploy.sh` ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/bd-scl-app-rds-node/img/10.11.jpg) **Figure 10.11:** Output of Deployment Script Using `**pm2**` logs you can monitor the application. Upon successful execution, the application will display the following output: `**$ pm2 logs**` `/home/ubuntu/.pm2/logs/pms-api-out.log last 15 lines:` `0|pms-api  | Router : RoleRoutes - Connected` `0|pms-api  | Router : UserRoutes - Connected` `0|pms-api  | Router : ProjectRoutes - Connected` `0|pms-api  | Router : TaskRoutes - Connected` `0|pms-api  | Router : CommentRoutes - Connected` `0|pms-api  | Server Routes started for server` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` `0|pms-api  | Server is running on port 8080 with pid = 95988` `0|pms-api  | Connected to the database` `0|pms-api  | Connected to the database` # Other Methods For Deployment Apart from deploying on AWS EC2 instances as we followed in this chapter, there are many other ways to deploy a Node.Js application. Amazon has other offerings for deployment such as AWS Lambda, AWS Elastic Beanstalk, and Amazon ECS/EKS. AWS lambda is ideal for event driven or microservices architecture applications. If you do not want to be worried about the underlying infrastructure and want to focus on just your application, Elastic Beanstalk is a great choice. It handles the deployment, load balancing, and auto scaling automatically. Docker containers can also be used to containerize your application and deploy with help of Amazon ECS and EKS. Similar to Amazon, Google Cloud Platform (GCP) also offers Google App Engine, Kubernetes Engine, and Cloud functions. Microsoft Azure offers Azure App Service, Azure Functions, and Azure Kubernetes Service (AKS). Heroku is another cloud platform which offers straightforward deployment of Node.js applications. It can automatically detect that your application is a Node.js application and will install the dependencies using NPM. # Conclusion In this chapter, we delve into making our application code secure using obfuscation, construction, and deployment of a Node.js application, employing the widely recommended processes and adopting a straightforward approach to AWS integration. This involves manual deployment on AWS, but you have the option to explore various methods, including Jenkins, CI/CD, Git pipelines, AWS CodeBuild, and pipelines, among others. These options encompass various DevOps tasks, so we are not enhancing more here. In summary, after the development phase, it is essential to ensure that your application reaches end-users. Deployment plays a crucial role in this process, as without it, everything remains in a state of readiness but lacks actual utilization. The next chapter, “*The Journey Ahead*” is the last chapter of the book. We will briefly discuss what we learned and what can be done further. # Multiple Choice Questions 1. What is code obfuscation? 1. Encryption technique for securing data. 2. Process of transforming source code to make it harder to understand. 3. Method for optimizing code performance. 4. Technique for compressing code files. 2. What are Anti-debugging techniques ? 1. Embedding code for detecting debugging attempts. 2. Changing representation of numerical constants. 3. Splitting functions into smaller pieces. 4. Compressing the overall code size. 3. What TypeScript is primarily used for in Node.js development? 1. Database management. 2. Code obfuscation. 3. Server-side scripting. 4. Browser compatibility. 4. What is the purpose of the command `tar czf pms_be_$VER.tgz *.js package.json` components routes tests utils? 1. It installs npm packages. 2. It compresses specific files and directories into a tarball. 3. It initializes a new Node.js project. 4. It extracts files from a tarball. 5. Which npm script is commonly used to run a Node.js application after TypeScript compilation? 1. `npm run build` 2. `npm start` 3. `npm install` 4. `npm run compile` 6. What is the significance of the variable `**$VER**` in the command? 1. It represents the version of Node.js. 2. It is used for encryption purposes. 3. It is a placeholder for the actual version number. 4. It specifies the compression format. 7. What is the purpose of an EC2 instance in AWS? 1. To store data in the cloud. 2. To manage DNS records. 3. To deploy and run applications on virtual servers. 4. To route traffic to different services. 8. Which file is used as the SSH key for connecting to the remote server in the script? 1. `PMS_KEY.pem` 2. `deploy.sh` 3. `server_config.json` 4. `mkpackage.sh` 9. What does the `process_pms_server` function in the script do? 1. Installs Node.js packages. 2. Deploys the Node.js application. 3. Configures the server’s security group. 4. Uninstalls Node.js from the server. 10. How does `**PM2**` help in managing Node.js applications? 1. It automates the installation of Node.js. 2. It monitors and manages the processes of Node.js applications. 3. It provides a graphical user interface for Node.js development. 4. It facilitates code profiling in Node.js. # Answers 1. b 2. a 3. c 4. b 5. b 6. c 7. c 8. a 9. b 10. b # Further Reading [`www.npmjs.com/package/pm2`](https://www.npmjs.com/package/pm2) [`docs.aws.amazon.com/ec2/`](https://docs.aws.amazon.com/ec2/) [`www.jenkins.io/`](https://www.jenkins.io/) [`www.heroku.com/`](https://www.heroku.com/) [`cloud.google.com`](https://cloud.google.com) [`azure.microsoft.com/en-us`](https://azure.microsoft.com/en-us) [`www.npmjs.com/package/javascript-obfuscator`](https://www.npmjs.com/package/javascript-obfuscator) ``

第十一章

前方的旅程

简介

发现可能的极限的唯一方法就是超越它们进入不可能的领域。” —— 亚瑟·C·克拉克

在整本书中,我们学习了利用 Node.Js 和 Express.Js 为项目管理软件所需 API 的重要概念和方法。通过这个单一示例,我们为用户、项目、任务等设计了必要的 API。如果你遵循了章节并完成了自己的 API 版本,你应该有一个功能齐全的项目管理软件的后端。

有很多更多的事情可以做,但这些都无法仅凭一本书来涵盖。本章将尝试涵盖可以进一步做的事情,以及可以遵循的最佳实践。在执行所有这些时,你应该手头有相当不错的软件。

旅程才刚刚开始。

结构

在本章中,我们将讨论以下主题:

  • 到目前为止的故事

  • 下一步

  • 保持领先

  • 进一步阅读

  • 总结

到目前为止的故事

回顾 Ryan Dahl 在 2009 年 JSConf 上的 Node.js 演示,我们开始学习 Node.js。从 Node.js 的基础知识和其与事件循环的美妙工作方式开始,我们学习了它可以用在哪里,并讨论了一些优缺点。

我们学习了在多个平台上安装 Node.js,并使用和未使用模块集群创建了基本的 HTTP 和 HTTPS 服务器。集群模块有助于利用机器的能力,并显著提高性能。

人们一直以通常的方式用 JavaScript 编写 Node.js 应用程序的代码,但后来微软在 2012 年发布了 TypeScript。由于其开源、跨平台,并且支持具有强大类型检查的对象编程,它通过检测错误和错误使开发者的生活变得更轻松。因此,开发者开始适应 TypeScript。

考虑到这一点,第二章,“TypeScript 简介”专注于涵盖 TypeScript 的基础知识。在安装必要的包之后,一个基本的应用程序和一些小示例帮助我们理解了语言的关键概念。

接下来,我们学习了 Express.js 及其如何轻松创建 API 端点。我们安装了 Express.js 的必要包,并熟悉了核心概念,包括其优缺点。

通过离散的示例理解 Express.js 中的 Node.js 应用程序是可行的,但开发一个统一的应用程序可以更深入地了解应用程序规划、设计和执行。因此,我们开始编写一个应用程序来理解所有这些。在第四章,规划应用程序中,我们从规划应用程序——项目管理系统开始。我们设置了项目、必要的依赖项、目录结构,并创建了所需的数据库表以及路由框架。

我们打下了基础,以便我们可以开始构建 API,这是一个项目管理软件所必需的。

对于任何企业应用程序,无论是仅后端 API 还是全栈,访问管理都是一个必备的功能。这正是我们所做的——通过创建用户管理的 API,并在章节中添加了“忘记密码”功能。我们创建了一个基于令牌的认证系统,使用了**jsonwebtoken**包。

一旦我们将用户纳入系统中,我们就开始在第六章,“项目与任务模块的 REST API”中编写项目和任务模块。我们涵盖了项目实体和任务实体的基本 CRUD 操作,以及分配给用户。

第七章,“API 缓存”专注于对性能提升至关重要的缓存。我们实现了 Redis 进行数据缓存,并开发了 Cache Util 来管理 Redis 交互。

在以沟通为关键点的 Web 应用中,集成一个通知模块是必不可少的。我们不仅实现了这一功能,在第八章,“通知模块”中,还利用 Redis 队列来提高其效率。作为一个使用通知系统的例子,我们修改了代码,以便在创建新任务时自动向相关项目成员发送通知。

不必说单元测试的重要性,我们在第九章,“测试 API”中学习了单元测试的基础。在必要的配置之后,我们使用了 Mocha 框架和 Chai 来编写单元测试。

为了最后的润色,我们深入到第十章,“构建和部署应用程序”的关键方面,同时涵盖了代码混淆的显著概念。

到目前为止的旅程是对使用 Node.js 和 Express.js 构建强大应用程序的广泛探索,涵盖了从基础知识到更高级的主题,如缓存、单元测试和部署策略。这应该会为你提供创建、优化和部署高效、可扩展 Web 应用程序的技能和知识。

下一步

在这本书中,只能涵盖“可能做到的”一小部分内容。根据上下文,可以进一步开发。在本节中,我们将列出一些可以开发以进一步增强应用程序的功能。

正在讨论的点可以被视为——可以进一步添加的内容,以及如何做到最佳实践的要点。以下点不仅涵盖了可以增强应用程序的内容,还涵盖了使其可扩展和可维护的方法。

前端

项目管理软件通常是一个网络应用程序。桌面应用程序的时代已经过去。现在是协作、沟通以及最重要的是可用性的时代。一个网络应用程序可以在世界上的任何地方使用。它可以通过笔记本电脑或手机访问,这满足了需求。前端开发不在本书的范围内。然而,让我们讨论一下如何完成它的一些方面。

在撰写本书时,有许多流行的框架——React、Angular、Vue.js。所有这些都能够使其成为一个动态且响应式的用户界面。此外,Bootstrap 可以帮助实现统一和响应式的样式。

项目管理应用程序的用户界面可能包含以下页面:

  • 仪表板:显示项目活动、分配任务的摘要、快速操作,如创建新任务、新项目等。所有项目、分配的项目、分配的任务的数量也可以以大数字的形式显示。

  • 项目:所有分配的项目都可以列成表格。一些按钮可以执行一些操作,例如添加新项目、管理项目成员资格,也可以为项目经理角色显示。对于列表中的每个项目,用户应该能够打开所有问题的列表、未解决问题列表等。这些选项可以根据需要定制,以便为 QA 团队成员打开测试中的问题列表。

  • 用户:管理员可以通过一个页面来管理用户。

  • 用户配置文件图标:右上角的一个图标,打开用户配置文件页面,用户可以管理自己的配置文件,例如,姓名、时区设置、密码、通知设置等。

  • 报告:对于项目经理来说,这可能是一个有用的页面来分析项目状态。

  • 服务器状态:一个管理员页面,可以显示当前的 CPU、内存和存储状态。

根据项目需求和规模,可能会有更多。

报告

报告是项目管理应用程序的一个关键方面。它可能看起来像一项巨大的任务,但实际上实施并不困难。

分析我们寻求的报告类型很重要。一组简单的时序图,显示活跃和关闭问题的数量,以展示项目的进度,可能是有用的。在敏捷开发的情况下,以速度显示项目进度可能很重要。

可以显示每个阶段(待办事项、开发、测试和关闭)的问题数量饼图。关于已打开问题数与关闭问题数的报告也是一个衡量项目表现的好指标。

报告需要在前端和后端进行关键更改。在后台创建一个专门的模块来生成报告是明智的。一些报告可以通过调度器(例如,每天午夜生成报告)生成,以持久化存储,以避免对每个请求进行重复处理。一些报告将是按需和实时的。报告模块的抽象将提供清晰的实现。

需要一个数据可视化库来生成带有图表的报告。开源库如**D3.js****Chart.js****Plotly****Vis**等是不错的选择。如果成本不是问题,**Highcharts****amCharts**是可尝试的顶级工具之一。

应用机器学习

在 Web 应用程序中应用机器学习有很多机会。如果你跟踪用户流量,流量激增的检测可以在适当的时间采取适当的行动,有助于保持高可用性。

通过跟踪用户活动并应用机器学习,可以为用户提供定制化的用户界面,使其应用更加引人入胜。

跟踪用户活动还可以帮助分析哪些操作或哪些页面比其他页面访问得更多。后端操作,如缓存、数据库查询等,可以针对顶级操作进行优化。

可以预测项目完成日期或敏捷开发中的冲刺性能。有无数这样的应用可以实施。

服务器监控

随着流量的增长,资源使用量也会增长,跟踪服务器的 CPU、RAM 和存储,以及数据库和网络至关重要。有方法可以监控服务器。

  • 使用内置操作系统工具:大多数操作系统,尤其是通常部署应用程序的服务器,提供用于监控资源如**top****htop****vmstat**等工具,适用于 Linux。

  • 应用程序内的独立模块:应用程序可以被修改以创建一个模块,该模块定期跟踪并存储数据库中的资源使用情况。这些数据可以在前端显示,并可视化。可以创建一个单独的页面。此外,当资源使用量超过设定的阈值时,可以实施警报。例如,如果可用 RAM 为 16 GB,则可以将阈值设置为 12 GB。当 RAM 使用量超过 12 GB 时,可以向管理员发送通知警报,采取必要的行动。

  • 自定义脚本:Python、Bash 或其他语言也可以用来编写脚本,这些脚本可以监控机器和服务器的外部资源使用情况,并将其与警报系统集成。

  • 其他工具:Prometheus 配合 Grafana 可以用来查看服务器的实时统计数据。

根据部署,其他工具可能会有所帮助。对于云部署,有特定于云提供商的工具,例如 AWS Cloudwatch、Azure Monitor 和 Google Cloud 操作套件。

安全功能

目前,应用程序使用令牌系统为用户提供身份验证和授权。我们使用**jsonwebtoken**创建了**access_token****refresh_token**。前端可以为每个请求使用**access_token**,如果**access_token**过期,并且**refresh_token**可用,则可以使用它来生成新的**access_token**

这可以通过其他授权类型和必要的**OAuth2**实现功能来改进。

SSL/TLS 加密

应该提供必要的 SSL 支持。这意味着,如果我们需要使用类似于 HTTPS://的 URL 托管应用程序,这是可能的,尽管可以有一些策略来实现这一点。一种策略是使应用程序可配置,以便在 HTTPS 和 HTTP 之间进行选择。另一种策略是在前端使用**nginx**并使用认证机构(CA)的帮助下使用 SSL 证书。Let us Encrypt 是一个免费服务,并且被广泛认可。它易于设置,并为安全通信提供了必要的加密级别。

社交媒体登录

如果应用程序将要公开,那么实现通过社交媒体平台(如 Google、Facebook、Twitter 等)登录可能是个不错的选择,以提供无缝的用户注册和登录。

双因素认证

不时地,我们会看到来自基础设施不太安全的网站的密码和其他细节被黑客攻击。实施双因素认证(2FA)以增加额外的安全层变得必不可少。

LDAP 集成

如果应用程序作为私有应用程序部署给一个组织,并且该组织已经部署了自己的 LDAP 服务器,那么创建一个与 LDAP 集成的应用程序用于用户管理将非常棒。它可以简化应用程序内的身份验证和授权过程。

基于容器的部署

有时,有必要将应用程序作为容器进行部署。使用 Docker 及其通过 Kubernetes 的帮助进行编排是可行的。它们一起提供了一个平滑且可扩展的部署。

要启用容器化部署,你需要创建:

  • Dockerfile:Docker 需要一个 Dockerfile 来指定基础镜像(运行应用程序所需的 Linux 版本和版本)、依赖项、变量、命令等。

  • **docker-compose.yml**:这是一个 YAML 文件,它定义了 Docker 容器在生产中的行为。这个文件与 Docker Compose 一起使用,Docker Compose 是一个用于运行多容器 Docker 应用程序的工具。使用此文件,我们还可以配置应用程序的服务。

  • Kubernetes 配置:我们需要编写部署、ConfigMaps 和其他文件的 Manifests 来配置 Kubernetes 以编排 Docker 容器。

通常,当需要这种专业知识时,组织内部会有专门的团队来完成这项工作。如果你愿意尝试,安装 Docker、编写简单的 Dockerfile 以及学习运行容器的基本命令是起点。

Swagger UI

由于目前实施的应用程序提供了 API,因此提供 API 测试和文档非常重要。Swagger UI 提供了一个基于 Web 的用户界面,用户可以查看 API 端点,访问与每个端点关联的文档,并直接从浏览器中调用它。

Swagger UI 是由swagger.io提供的工具。它因其易用性、测试端点和访问文档的集中性、安全性以及实时准确性而受到欢迎。

这不是要向网站用户展示的内容,例如项目经理和成员,因为他们不需要了解底层端点。然而,对于开发人员和 QA 团队成员来说,这是一个必不可少的工具。

保持领先

在撰写本文时,有许多大型语言模型(LLMs)可用,例如 OpenAI 的 ChatGPT 和 Google 的 Gemini。这不再是传统的机器学习。这是一个生成式人工智能的时代。AI 不再只是预测一个值,例如房价,或将某物分类到类别中的工具。AI 还可以为我们生成内容。给定一个上下文,AI 可以立即提供一段文字、一页或一本书。AI 可以在代码中找到错误。令人印象深刻的是,它还可以编写代码。GitHub 的 co-pilot 能够提供与你的应用程序相关的即时代码片段。如果你愿意,AI 还可以教你一个主题。

考虑到所有这些,思考并记录下生成式 AI 如何帮助是非常重要的。以我们的主要示例项目管理系统为例,我们可以给出一个基本的想法,即我们试图管理什么,AI 可以帮助我们创建任务。如果我们向 AI 提供完整的软件需求规格说明书(SRS),它可以生成所有任务的详细信息。

生成式 AI 可以在以下关键领域(保持聚焦于 PMS)中使用:

  • 项目规划:如果提供了输入目标,例如我们试图通过项目实现什么,资源数量、时间表和约束条件,GenAI 可以帮助制定全面的项目计划、冲刺或里程碑规划。

  • 风险评估:如果项目状态不符合里程碑时间表,可以提供风险评估。

  • 资源分配:如果要从资源池中选择资源,考虑到其他项目中资源的工作历史,可以为项目选择合适的资源做出决定。

  • 准备报告:根据项目的当前状态,可以生成自动报告。

  • 任务优先级:任务可以在项目开始时给予优先级,如果需要,可以根据项目状态进行更改。

  • 预测分析:根据任务的复杂度、状态以及所处的阶段,可以预测下一个里程碑的完成日期。

  • 准备经验总结:在项目结束时,可以准备一份经验总结列表,以避免下次犯错误。

  • 实时情感分析:通常由开发者对任务进行评论。可以进行实时情感分析。

GenAI 可能有更多帮助的方式。

进一步阅读

本书涵盖了 Node.js、TypeScript、Express.js、Mocha、Chai、Redis 以及一些其他工具。一本涵盖众多方面的书只能触及基础知识到中等水平。为了进一步学习,这里有一些建议供您尝试。

高级 Node.js 开发

可以进一步学习诸如流、子进程、性能优化等高级主题。此外,使用装饰器和设计模式可以使代码整洁、几乎零缺陷且易于维护。

Node.js 也可以是需要重型、实时通信的应用程序的一个很好的选择。例如,Socket.io 和 websockets 等技术提供了必要的功能。

一种流行的架构是微服务。可以考虑进一步学习专注于微服务架构的资源。

全栈开发

浏览器只理解三件事——HTML、CSS 和 JavaScript。

由于底层语言 JavaScript 对于后端(例如 Node.js)和前端(例如 React、Angular、Vue 以及更多框架)都是相同的,因此成为一名全栈开发者变得相对容易。

在对 Node.js/TypeScript 有良好理解之后,熟悉 React 或 Angular 成为了一种常见的趋势,正如最近所见。

测试驱动开发

有许多书籍提供了使用测试驱动开发实施项目的指导。如果严格遵守 TDD 方法,将导致无 bug 的项目。这不仅允许你在早期阶段捕捉到 bug,还降低了开发和维护的成本。

在这种方法中,首先编写测试用例,然后编写代码以便测试通过。因此,编写涵盖所有可能输入的测试用例至关重要。

当你谈论 TDD 时,通常会有一种消极的心态。它主要关于编写测试用例所需的时间和学习。然而,当进行研究时,与没有 TDD 的项目相比,整体时间和成本要低得多。

性能调优

在生活中,你会有意识到应用程序中使用的查询比预期花费更多时间的时候。你尝试通过使用索引、增加 RAM 大小等方式优化数据库。然而,数据库管理和优化的内容远不止这些调整。

应用程序逻辑是进行优化的另一个地方。找到内存泄漏和占用 CPU 的逻辑至关重要。可怕的是,开发者往往在开发时忘记或没有注意优化。有时,压力测试也遵循相同的路径,因此它们没有涵盖代码的所有部分,因此一些问题仍未被发现。这些问题出现在生产实例中,那时,修复它们变得具有挑战性。

其他主题

除了上述主题之外,关于无服务器架构、DevOps 实践和 Node.js 中的函数式编程的资源也可以尝试。

AI/ML、数据分析与可视化库的集成是进入数据科学背景的关键。

另一个重要的方面是应用程序的安全性。尽管我们已经涵盖了 OAuth 和 JWT 等主题,但关于如何保护 Node.js 应用程序,还有更多可以学习的内容。

结语

当我们到达本书的结尾时,反思我们所走过的广阔领域是很重要的。从基础知识到构建具有身份验证、缓存和单元测试的 API,我们已经覆盖了大量的内容。

随着你进一步前进,你将见证 JavaScript 的力量,并意识到为什么它正在成为有史以来最受欢迎的语言。TypeScript 的引入通过提供类型安全和提高代码质量使其变得更加强大。

记住,网络开发的领域是不断变化的。技术不断发展。十年前,Java、Dot net 和 Python 是一切。今天,JavaScript 是最受欢迎的语言,对于许多开发者来说,它是不可或缺的。Stackoverlfow.com 在 2022 年的开发者调查中显示,67% 的开发者使用 JavaScript 进行工作。

随着你继续你的旅程,继续实验,继续创新,继续学习。最重要的是,保持你对编码、学习和尝试新事物的热情。毕竟,旅程才刚刚开始

posted @ 2025-10-11 12:57  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报