NestJS-Node-渐进式框架-全-

NestJS:Node 渐进式框架(全)

原文:zh.annas-archive.org/md5/04CAAD35859143A3EB7D2A8730043240

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

什么是 Nest.js?

有很多可用的 Web 框架,随着 Node.js 的出现,发布的框架更是层出不穷。随着 Web 技术的变化和发展,JavaScript 框架很快就会进入和退出流行。Nest.js 对许多开发人员来说是一个很好的起点,因为它使用一种非常类似于迄今为止最常用的 Web 语言 JavaScript 的语言。许多开发人员是使用诸如 Java 或 C/C++之类的语言来学习编程的,这两种语言都是严格的语言,因此使用 JavaScript 可能会有点尴尬,并且由于缺乏类型安全性,容易出错。Nest.js 使用 TypeScript,这是一个很好的折衷方案。它是一种语言,提供了 JavaScript 的简单性和强大性,同时又具有您可能习惯的其他语言的类型安全性。Nest.js 中的类型安全性仅在编译时可用,因为 Nest.js 服务器被编译为运行 JavaScript 的 Node.js Express 服务器。然而,这仍然是一个重大优势,因为它允许您在运行时之前更好地设计无错误的程序。

Node.js 在 NPM(Node Package Manager)中拥有丰富的软件包生态系统。拥有超过 35 万个软件包,它是世界上最大的软件包注册表。使用 Express 的 Nest.js 在开发 Nest 应用程序时,您可以访问每一个这些软件包。许多软件包甚至为其软件包提供了类型定义,允许 IDE 读取软件包并提供建议/自动填充代码,这在跨 JavaScript 代码与 TypeScript 代码交叉时可能是不可能的。Node.js 最大的好处之一是可以从中提取模块的庞大存储库,而不必编写自己的模块。Nest.js 已经将其中一些模块包括在 Nest 平台的一部分中,比如@nestjs/mongoose,它使用 NPM 库mongoose。在 2009 年之前,JavaScript 主要是一种前端语言,但在 2009 年 Node.js 发布之后,它推动了许多 JavaScript 和 TypeScript 项目的开发,如:Angular、React 等。Angular 对 Nest.js 的开发产生了很大的启发,因为两者都使用了允许可重用的模块/组件系统。如果您不熟悉 Angular,它是一个基于 TypeScript 的前端框架,可用于跨平台开发响应式 Web 应用程序和原生应用程序,并且它的功能与 Nest 非常相似。两者在一起也非常搭配,Nest 提供了运行通用服务器的能力,以提供预渲染的 Angular 网页,以加快网站交付时间,使用了上面提到的服务器端渲染(SSR)。

关于示例

本书将引用一个托管在 GitHub 上的 Nest.js 项目的示例(https://github.com/backstopmedia/nest-book-example)。在整本书中,代码片段和章节将引用代码的部分,以便您可以看到您所学习的内容的一个工作示例。示例 Git 存储库可以在命令提示符中克隆。

git clone https://github.com/backstopmedia/nest-book-example.git

这将在您的计算机上创建项目的本地副本,您可以通过使用 Docker 构建项目在本地运行:

docker-compose up

一旦您的 Docker 容器在本地主机:3000 端口上运行起来,您将希望在做任何其他事情之前运行迁移。要做到这一点,请运行:

docker ps

获取正在运行的 Docker 容器的 ID:

docker exec -it [ID] npm run migrate up

这将运行数据库迁移,以便您的 Nest.js 应用程序可以使用正确的模式读取和写入数据库。

如果您不想使用 Docker,或者无法使用 Docker,您可以使用您选择的软件包管理器(如npmyarn)构建项目:

npm install

yarn

这将在您的node_modules文件夹中安装依赖项。然后运行:

npm start:dev

或以下内容启动您的 Nest.js 服务器:

yarn start:dev

这些将运行nodemon,如果有任何更改,将导致您的 Nest.js 应用程序重新启动,使您无需停止、重建和重新启动应用程序。

关于作者

  • 格雷格·马戈兰(Greg Magolan)是 Rangle.io 的高级架构师、全栈工程师和 Angular 顾问。他在 Agilent Technologies、Electronic Arts、Avigilon、Energy Transfer Partners、FunnelEnvy、Yodel 和 ACM Facility Safety 等公司工作了 15 年以上,开发企业软件解决方案。

  • 杰伊·贝尔(Jay Bell)是 Trellis 的首席技术官。他是一名资深的 Angular 开发人员,使用 Nest.js 在生产中开发领先行业的软件,帮助加拿大的非营利组织和慈善机构。他是一位连续创业者,曾在许多行业开发软件,从利用无人机帮助打击森林大火到构建移动应用程序。

  • 大卫·吉哈罗(David Guijarro)是 Car2go Group GmbH 的前端开发人员。他在 JavaScript 生态系统内有丰富的工作经验,成功建立并领导了多元文化、多功能团队。

  • 阿德里安·德佩雷蒂(Adrien de Peretti)是一名全栈 JavaScript 开发人员。他对新技术充满热情,不断寻找新挑战,特别对人工智能和机器人领域感兴趣。当他不在电脑前时,阿德里安喜欢在大自然中玩各种运动。

  • 帕特里克·豪斯利(Patrick Housley)是 VML 的首席技术专家。他是一名拥有超过六年技术行业经验的 IT 专业人士,能够分析涉及多种技术的复杂问题,并提供详细的解决方案和解释。他具有强大的前端开发技能,有领导开发团队进行维护和新项目开发的经验。

第一章:介绍

每个 Web 开发人员都严重依赖于一个或多个 Web 框架(有时如果他们的服务有不同的要求,会使用更多),而公司将依赖于许多框架,但每个框架都有其优缺点。这些框架正是为开发人员提供一个框架,提供基本功能,任何 Web 框架必须提供这些功能,才能被认为是开发人员或公司在其技术栈中使用的一个好选择。在本书中,我们将讨论您期望在像 Nest 这样的先进框架中看到的框架的许多部分。这些包括:

  1. 依赖注入

  2. 认证

  3. ORM

  4. REST API

  5. Websockets

  6. 微服务

  7. 路由

  8. Nest 特定工具的解释

  9. OpenApi(Swagger)文档

  10. 命令查询责任分离(CQRS)

  11. 测试

  12. 使用 Universal 和 Angular 进行服务器端渲染。

Nest 提供了更多这些功能,因为它是建立在 Node.js Express 服务器之上的现代 Web 框架。通过利用现代 ES6 JavaScript 的弹性和 TypeScript 在编译时强制类型安全,Nest 在设计和构建服务器端应用程序时将可扩展的 Node.js 服务器提升到一个全新的水平。Nest 将三种不同的技术结合成一个成功的组合,允许高度可测试、可扩展、松散耦合和可维护的应用程序。这些技术包括:

  1. 面向对象编程(OOP):一个围绕对象而不是动作和可重用性而不是利基功能构建的模型。

  2. 函数式编程(FP):设计不依赖于全局状态的确定功能,即函数 f(x)对于一些不变的参数每次返回相同的结果。

  3. 函数式响应式编程(FRP):是上述 FP 和响应式编程的扩展。函数式响应式编程在其核心是考虑时间流的函数式编程。它在 UI、模拟、机器人和其他应用程序中非常有用,其中特定时间段的确切答案可能与另一个时间段的答案不同。

讨论的主题

以下每个主题将在接下来的章节中详细讨论。

Nest CLI

在 Nest 的第 5 版中,有一个 CLI 可以允许通过命令行生成项目和文件。可以通过以下命令全局安装 CLI:

npm install -g @nestjs/cli

或者通过 Docker:

docker pull nestjs/cli:[version]

可以使用以下命令生成新的 Nest 项目:

nest new [project-name]

此过程将从typescript-starter创建项目,并将要求输入namedescriptionversion(默认为 0.0.0)和author(这将是您的名字)。完成此过程后,您将拥有一个完全设置好的 Nest 项目,并且依赖项已安装在您的node_modules文件夹中。new命令还将询问您想要使用哪种包管理器,就像yarnnpm一样。Nest 在创建过程中为您提供了这个选择。

CLI 中最常用的命令将是generate(g)命令,这将允许您创建 Nest 支持的新的controllersmodulesservies或任何其他组件。可用组件的列表如下:

  1. class(cl)

  2. controller(co)

  3. decorator(d)

  4. exception(e)

  5. filter(f)

  6. gateway(ga)

  7. guard(gu)

  8. interceptor(i)

  9. middleware(mi)

  10. module(mo)

  11. pipe(pi)

  12. provider(pr)

  13. service(s)

请注意,括号中的字符串是该特定命令的别名。这意味着您可以输入:

nest generate service [service-name]

在控制台中,您可以输入:

nest g s [service-name]

最后,Nest CLI 提供了info(i)命令来显示关于您的项目的信息。此命令将输出类似以下内容的信息:

[System Information]
OS Version     : macOS High Sierra
NodeJS Version : v8.9.0
YARN Version    : 1.5.1
[Nest Information]
microservices version : 5.0.0
websockets version    : 5.0.0
testing version       : 5.0.0
common version        : 5.0.0
core version          : 5.0.0

依赖注入

依赖注入是一种技术,它通过将依赖对象(如服务)注入到组件的构造函数中,从而将依赖对象(如模块或组件)提供给依赖对象。下面是来自 sequelize 章节的一个示例。在这里,我们将UserRespository服务注入到UserService的构造函数中,从而在UserService组件内部提供对用户数据库存储库的访问。

@Injectable()
export class UserService implements IUserService {
    constructor(@Inject('UserRepository') private readonly UserRepository: typeof User) { }
    ...
}

反过来,这个UsersService将被注入到src/users/users.controller.ts文件中的UsersController中,这将为指向该控制器的路由提供对UsersService的访问。更多关于路由和依赖注入的内容将在后面的章节中讨论。

认证

认证是开发中最重要的方面之一。作为开发人员,我们始终希望确保用户只能访问他们有权限访问的资源。认证可以采用多种形式,从展示您的驾驶执照或护照到为登录门户提供用户名和密码。近年来,这些认证方法已经扩展到变得更加复杂,但我们仍然需要相同的服务器端逻辑,以确保这些经过认证的用户始终是他们所说的那个人,并保持这种认证,这样他们就不需要为每次对 REST API 或 Websocket 的调用重新进行认证,因为那将提供非常糟糕的用户体验。选择的库恰好也被命名为 Passport,并且在 Node.js 生态系统中非常知名和使用。在 Nest 中集成时,它使用 JWT(JSON Web Token)策略。Passport 是一个中间件,HTTP 调用在到达控制器端点之前会经过它。这是为示例项目编写的AuthenticationMiddleware,它扩展了NestMiddleware,根据请求负载中的电子邮件对每个用户进行认证。

@Injectable()  
export class AuthenticationMiddleware implements NestMiddleware {  
   constructor(private userService: UserService) { }  

   async resolve(strategy: string): Promise<ExpressMiddleware> {  
       return async (req, res, next) => {  
           return passport.authenticate(strategy, async (/*...*/args: any[]) => {  
               const [, payload, err] = args;  
                if (err) {  
                    return res.status(HttpStatus.BAD_REQUEST).send('Unable to authenticate the user.');  
                }  

               const user = await this.userService.findOne({
                    where: { email: payload.email }
               });  
                req.user = user;  
                return next();  
            })(req, res, next);  
        };  
    }  
}

Nest 还实现了守卫,它们与其他提供者一样使用@Injectable()进行装饰。守卫基于经过认证的用户所拥有的访问权限来限制某些端点。守卫将在认证章节中进一步讨论。

ORM

ORM 是对象关系映射,是处理服务器和数据库之间通信时最重要的概念之一。ORM 提供了内存中对象(如UserComment这样的定义类)与数据库中的关系表之间的映射。这使您可以创建一个数据传输对象,它知道如何将存储在内存中的对象写入数据库,并从 SQL 或其他查询语言中读取结果,再次存入内存。在本书中,我们将讨论三种不同的 ORM:两种关系型数据库和一种 NoSQL 数据库。TypeORM 是 Node.js 中最成熟和最流行的 ORM 之一,因此具有非常广泛和完善的功能集。它也是 Nest 提供自己的包之一:@nestjs/typeorm。它非常强大,并支持许多数据库,如 MySQL、PostgreSQL、MariaDB、SQLite、MS SQL Server、Oracle 和 WebSQL。除了 TypeORM,Sequelize 也是另一个用于关系数据的 ORM。

如果 TypeORM 是最受欢迎的 ORM 之一,那么 Sequelize 就是 Node.js 世界中最受欢迎的 ORM。它是用纯 JavaScript 编写的,但通过sequelize-typescript@types/sequelize包具有 TypeScript 绑定。Sequelize 拥有强大的事务支持、关系、读取复制和许多其他功能。本书涵盖的最后一个 ORM 是处理非关系型或 NoSQL 数据库的 ORM。包mongoose处理了 MongoDB 和 JavaScript 之间的对象关系。实际的映射比与关系数据库更接近,因为 MongoDB 以 JSON 格式存储其数据,JSON 代表 JavaScript 对象表示法。Mongoose 也是具有@nestjs/mongoose包的包之一,并提供通过查询链接查询数据库的能力。

REST API

REST 是创建 API 的主要设计范式之一。它代表着表现状态转移,并使用 JSON 作为传输格式,这与 Nest 存储对象的方式一致,因此它是用于消费和返回 HTTP 调用的自然选择。REST API 是本书讨论的许多技术的组合。它们以一定的方式组合在一起;客户端向服务器发起 HTTP 调用。服务器将根据 URL 和 HTTP 动词路由调用到正确的控制器,可选择性地通过一个或多个中间件传递到控制器之前。控制器然后将其交给服务进行处理,这可能包括通过 ORM 与数据库通信。如果一切顺利,服务器将向客户端返回一个 OK 响应,如果客户端请求资源(GET 请求),则可能包含一个可选的主体,或者如果是 POST/PUT/DELETE,则只返回一个 200/201 HTTP OK,而没有响应主体。

WebSockets

WebSockets 是连接到服务器并发送/接收数据的另一种方式。使用 WebSockets,客户端将连接到服务器,然后订阅特定的频道。然后客户端可以将数据推送到已订阅的频道。服务器将接收这些数据,然后将其广播给订阅了特定频道的每个客户端。这允许多个客户端都实时接收更新,而无需手动进行 API 调用,可能会通过 GET 请求向服务器发送大量请求。大多数聊天应用程序使用 WebSockets 来实现实时通信,群组消息中的每个成员发送消息后,所有成员都会立即收到消息。Websockets 允许更多地以流式传输数据的方式来传输数据,而不是传统的请求-响应 API,因为 Websockets 会在接收到数据时广播数据。

微服务

微服务允许 Nest 应用程序以一组松散耦合的服务的形式进行结构化。在 Nest 中,微服务略有不同,因为它们是使用除 HTTP 之外的不同传输层的应用程序。这一层可以是 TCP 或 Redis pub/sub 等。Nest 支持 TCP 和 Redis,尽管如果您使用其他传输层,可以通过使用CustomTransportStrategy接口来实现。微服务很棒,因为它们允许团队在全局项目中独立于其他团队的微服务进行工作,并对服务进行更改,而不会影响项目的其他部分,因为它是松散耦合的。这允许持续交付和持续集成,而不受其他团队微服务的影响。

GraphQL

正如我们在上面看到的,REST 是设计 API 时的一种范式,但现在有一种新的方式来考虑创建和使用 API:GraphQL。使用 GraphQL,每个资源都不再有自己指向它的 URL,而是 URL 将接受一个带有 JSON 对象的查询参数。这个 JSON 对象定义了要返回的数据的类型和格式。Nest 通过@nestjs/graphql包提供了这方面的功能。这将在项目中包括GraphQLModule,它是 Apollo 服务器的包装器。GraphQL 是一个可以写一整本书的主题,所以我们在本书中不再深入讨论它。

路由

路由是讨论 Web 框架的核心原则之一。客户端需要知道如何访问服务器的端点。这些端点中的每一个描述了如何检索/创建/操作存储在服务器上的数据。描述 API 端点的每个Component必须具有一个@Controller('prefix')装饰器,用于描述此组件端点集的 API 前缀。

@Controller('hello')
export class HelloWorldController {
  @Get(‘world’)
  printHelloWorld() {
    return ‘Hello World’;
  }
}

上述控制器是GET /hello/world的 API 端点,将返回一个带有Hello WorldHTTP 200 OK。这将在路由章节中进一步讨论,您将了解如何使用 URL 参数、查询参数和请求对象。

Nest 特定工具

Nest 提供了一组特定于 Nest.js 的工具,可以在整个应用程序中使用,帮助编写可重用的代码并遵循 SOLID 原则。这些装饰器将在后续的每一章中使用,因为它们定义了特定的功能:

  1. @Module:项目中可重用代码的定义,它接受以下参数来定义其行为。⋅⋅ 导入:这些是包含在此模块中使用的组件的模块。⋅⋅导出:这些是将在其他模块中使用的组件,导入此模块的模块。⋅⋅ 组件:这些组件将可供至少通过 Nest 注入器共享此模块。⋅⋅控制器:在此模块中创建的控制器,这些控制器将根据定义的路由定义 API 端点。

  2. @Injectable:Nest 中几乎所有东西都是可以通过构造函数注入的提供者。提供者使用@Injectable()进行注释。.. 中间件:在请求传递到路由处理程序之前运行的函数。在本章中,我们将讨论中间件、异步中间件和功能中间件之间的区别。..拦截器:类似于中间件,它们在方法执行前后绑定额外的逻辑,并且可以转换或完全覆盖函数。拦截器受面向方面的编程(AOP)的启发。.. 管道:类似于拦截器功能的一部分,管道将输入数据转换为所需的输出。..守卫:更智能、更专业的中间件,守卫的唯一目的是确定请求是否应该由路由处理程序处理。..*Catch:告诉ExceptionFilter要查找的异常,然后将数据绑定到它。

  3. @Catch:将元数据绑定到异常过滤器,并告诉 Nest 过滤器仅寻找@Catch中列出的异常。

注意:在 Nest 版本 4 中,上面列出的@Injectable()下的不是所有东西都使用@Injectable()装饰器。组件、中间件、拦截器、管道和守卫各自都有自己的装饰器。在 Nest 版本 5 中,这些都已合并为@Injectable(),以减少 Nest 和 Angular 之间的差异。

OpenAPI(Swagger)

在编写 Nest 服务器时,文档非常重要,特别是在创建将被其他人使用的 API 时,否则最终将使用 API 的客户端的开发人员不知道该发送什么或者他们会得到什么。其中最流行的文档引擎之一是 Swagger。与其他文档引擎一样,Nest 提供了专门用于 OpenAPI(Swagger)规范的模块@nestjs/swagger。该模块提供装饰器来描述 API 的输入/输出和端点。然后可以通过服务器上的端点访问此文档。

命令查询责任分离(CQRS)

命令查询责任分离(CQRS)是每个方法应该是执行操作(命令)或请求数据(查询)的想法,但不能两者兼而有之。在我们示例应用程序的上下文中,我们不会在端点的控制器中直接使用数据库访问代码,而是创建一个组件(数据库服务),该组件具有诸如getAllUsers()的方法,该方法将返回控制器服务可以调用的所有用户,从而将问题和答案分离到不同的组件中。

测试

测试您的 Nest 服务器将是至关重要的,以便一旦部署,就不会出现意外问题,并且一切都能顺利运行。在本书中,您将了解两种不同类型的测试:单元测试和 E2E 测试(端到端测试)。单元测试是测试小片段或代码块的艺术,这可能是测试单个函数或为ControllerInterceptor或任何其他Injectable编写测试。有许多流行的单元测试框架,JasmineJest是其中两个流行的框架。Nest 提供了专门的包,特别是@nestjs/testing,用于在*.spec.ts*.test.ts类中编写单元测试。

E2E 测试是常用的另一种测试形式,与单元测试不同之处在于它测试的是整个功能,而不是单个函数或组件,这就是所谓的端到端测试的由来。最终,应用程序会变得如此庞大,以至于很难测试每一行代码和端点。在这种情况下,您可以使用 E2E 测试来测试应用程序从开始到结束,以确保一切顺利进行。对于 E2E 测试,Nest 应用程序可以再次使用Jest库来模拟组件。除了Jest,您还可以使用supertest库来模拟 HTTP 请求。

测试是编写应用程序的非常重要的一部分,不应被忽视。无论您最终使用什么语言或框架,这都是一个相关的章节。大多数大型开发公司都有专门的团队负责为推送到生产应用程序的代码编写测试,这些团队被称为 QA 开发人员。

使用 Angular Universal 进行服务器端渲染

Angular 是一个客户端应用程序开发框架,而 Angular Universal 是一种技术,允许我们的 Nest 服务器预渲染网页并将其提供给客户端,这有许多好处,将在“使用 Angular Universal 进行服务器端渲染”章节中讨论。Nest 和 Angular 非常搭配,因为它们都使用 TypeScript 和 Node.js。许多可以在 Nest 服务器中使用的包也可以在 Angular 应用程序中使用,因为它们都编译为 JavaScript。

总结

在本书中,您将更详细地了解上述每个主题,不断构建在先前概念的基础上。Nest 提供了一个清晰、组织良好的框架,以简单而高效的方式实现每个概念,这是因为框架的模块化设计在所有模块中都是一致的。

第二章:概述

在本章中,我们将概述 Nest.js,并查看构建 Nest.js 应用程序所需的核心概念。

控制器

Nest 中的控制器负责处理传入的请求并向客户端返回响应。Nest 将传入的请求路由到控制器类中的处理程序函数。我们使用@Controller()装饰器来创建控制器类。

import { Controller, Get } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get()
    index(): Entry[] {
        const entries: Entry[] = this.entriesService.findAll();
        return entries;
    }

我们将在路由和请求处理章节中详细讨论路由和处理请求的细节。

提供者

Nest 中的提供者用于创建服务、工厂、助手等,这些可以被注入到控制器和其他提供者中,使用 Nest 内置的依赖注入。@Injectable()装饰器用于创建提供者类。

例如,我们博客应用程序中的AuthenticationService是一个提供者,它注入并使用UsersService组件。

@Injectable()
export class AuthenticationService {
    constructor(private readonly userService: UserService) {}

    async validateUser(payload: {
        email: string;
        password: string;
    }): Promise<boolean> {
        const user = await this.userService.findOne({
            where: { email: payload.email }
        });
        return !!user;
    }
}

我们将在依赖注入章节中更多地讨论依赖注入。

模块

Nest.js 应用程序被组织成模块。如果您熟悉 Angular 中的模块,那么 Nest 使用的模块语法将看起来非常熟悉。

每个 Nest.js 应用程序都将有一个根模块。在一个小应用程序中,这可能是唯一的模块。在一个较大的应用程序中,将应用程序组织成多个模块是有意义的,这些模块将您的代码分割成功能和相关功能。

Nest.js 中的模块是带有@Module()装饰器的类。@Module()装饰器接受一个描述模块的单个对象,使用以下属性。

属性 描述
components 要实例化的组件,可以在此模块中共享,并导出以供其他模块使用
controllers 由此模块创建的控制器
imports 要导入的模块列表,这些模块导出了此模块中需要的组件
exports 从此模块导出的组件列表,可供其他模块使用

在我们的示例应用程序中,根模块名为AppModule,应用程序分为多个子模块,这些子模块处理应用程序的主要部分,如身份验证、评论、数据库访问、博客条目和用户。

@Module({
    components: [],
    controllers: [],
    imports: [
        DatabaseModule,
        AuthenticationModule.forRoot('jwt'),
        UserModule,
        EntryModule,
        CommentModule,
        UserGatewayModule,
        CommentGatewayModule
    ],
    exports: [],
})
export class AppModule implements NestModule {}

AppModule 导入应用程序所需的模块。我们的应用程序中的根模块不需要有任何exports,因为没有其他模块导入它。

根模块也没有任何componentscontrollers,因为这些都是在它们相关的子模块中组织的。例如,EntryModule包括与博客条目相关的componentscontrollers

@Module({
    components: [entryProvider, EntryService],
    controllers: [EntryController],
    imports: [],
    exports: [EntryService],
})
export class EntryModule implements NestModule {}

在 Nest.js 中,模块默认是单例的。这意味着您可以在模块之间共享导出组件的相同实例,例如上面的EntryService,而无需任何努力。

引导

每个 Nest.js 应用程序都需要进行引导。这是通过使用NestFactory创建根模块并调用listen()方法来完成的。

在我们的示例应用程序中,入口点是main.ts,我们使用 async/await 模式创建AppModule并调用listen()

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

中间件

Nest.js 中间件可以是一个函数,也可以是一个使用@Injectable()装饰器实现NestMiddleware接口的类。中间件在路由处理程序之前被调用。这些函数可以访问请求响应对象,并且可以对请求和响应对象进行更改。

可以为路由配置一个或多个中间件函数,并且中间件函数可以选择将执行传递给堆栈上的下一个中间件函数,或者结束请求-响应周期。

如果中间件函数没有结束请求-响应周期,它必须调用next()将控制权传递给堆栈上的下一个中间件函数,或者如果它是堆栈上的最后一个函数,则传递给请求处理程序。未能这样做将使请求挂起。

例如,在我们的博客应用程序中,AuthenticationMiddleware负责对访问博客的用户进行身份验证。

import {
    MiddlewareFunction,
    HttpStatus,
    Injectable,
    NestMiddleware
} from '@nestjs/common';
import * as passport from 'passport';
import { UserService } from '../../modules/user/user.service';

@Injectable()
export class AuthenticationMiddleware implements NestMiddleware {
    constructor(private userService: UserService) {}

    async resolve(strategy: string): Promise<MiddlewareFunction> {
        return async (req, res, next) => {
            return passport.authenticate(strategy, async (...args: any[]) => {
                const [, payload, err] = args;
                if (err) {
                    return res
                        .status(HttpStatus.BAD_REQUEST)
                        .send('Unable to authenticate the user.');
                }

                const user = await this.userService.findOne({
                    where: { email: payload.email }
                });
                req.user = user;
                return next();
            })(req, res, next);
        };
    }
}

如果身份验证失败,将向客户端发送 400 响应。如果身份验证成功,那么将调用next(),并且请求将继续通过中间件堆栈,直到到达请求处理程序。

中间件是在 Nest.js 模块的configure()函数中配置在路由上的。例如,上面的AuthenticationMiddleAppModule中配置如下所示。

@Module({
    imports: [
        DatabaseModule,
        AuthenticationModule.forRoot('jwt'),
        UserModule,
        EntryModule,
        CommentModule,
        UserGatewayModule,
        CommentGatewayModule,
        KeywordModule
    ],
    controllers: [],
    providers: []
})
export class AppModule implements NestModule {
    public configure(consumer: MiddlewareConsumer) {
        const userControllerAuthenticatedRoutes = [
            { path: '/users', method: RequestMethod.GET },
            { path: '/users/:id', method: RequestMethod.GET },
            { path: '/users/:id', method: RequestMethod.PUT },
            { path: '/users/:id', method: RequestMethod.DELETE }
        ];

        consumer
            .apply(AuthenticationMiddleware)
            .with(strategy)
            .forRoutes(
                ...userControllerAuthenticatedRoutes,
                EntryController,
                CommentController
            );
    }
}

您可以将中间件应用到控制器上的所有路由,就像EntryControllerCommentController中所做的那样。您还可以根据路径将中间件应用到特定路由上,就像从UserController中的子集路由中所做的那样。

守卫

守卫是用@Injectable()装饰器修饰并实现CanActivate接口的类。守卫负责确定请求是否应该由路由处理程序或路由处理。守卫在每个中间件之后执行,但在管道之前执行。与中间件不同,守卫可以访问ExecutionContext对象,因此它们确切地知道将要评估的内容。

在我们的博客应用程序中,我们在UserController中使用CheckLoggedInUserGuard,只允许用户访问和访问自己的用户信息。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class CheckLoggedInUserGuard implements CanActivate {
    canActivate(
        context: ExecutionContext
    ): boolean | Promise<boolean> | Observable<boolean> {
        const req = context.switchToHttp().getRequest();
        return Number(req.params.userId) === req.user.id;
    }
}

@UseGuards装饰器用于将守卫应用到路由上。这个装饰器可以用在控制器类上,将守卫应用到该控制器的所有路由上,也可以用在控制器中的单个路由处理程序上,就像UserController中所示的那样:

@Controller('users')
export class UserController {
    constructor(private readonly userService: UserService) { }

    @Get(':userId')
    @UseGuards(CheckLoggedInUserGuard)
    show(@Param('userId') userId: number) {
        const user: User = this.userService.findById(userId);
        return user;
    }

总结

在本章中,我们介绍了 Nest.js 控制器、提供者、模块、引导和中间件。在下一章中,我们将介绍 Nest.js 身份验证。

第三章:Nest.js 认证

Nest.js,在版本 5 中,@nestjs/passport 软件包允许您实现所需的认证策略。当然,您也可以使用 passport 手动执行此操作。

在本章中,您将看到如何通过将其集成到 Nest.js 项目中来使用 passport。我们还将介绍策略是什么,以及如何配置策略以与 passport 一起使用。

我们还将使用认证中间件来管理限制访问,并查看守卫如何在用户访问处理程序之前检查数据。此外,我们将展示如何使用 Nest.js 提供的 passport 软件包,以涵盖两种可能性。

作为示例,我们将使用以下存储库文件:

  • /src/authentication

  • /src/user

  • /shared/middlewares

  • /shared/guards

Passport

Passport 是一个众所周知的流行且灵活的库。事实上,passport 是一种灵活的中间件,可以完全自定义。Passport 允许不同的方式来验证用户,如以下方式:

  • 本地策略 允许您仅使用自己的数据 emailpassword 来验证用户,在大多数情况下。

  • jwt 策略 允许您通过提供令牌并使用 jsonwebtoken 验证此令牌来验证用户。这种策略被广泛使用。

一些策略使用社交网络或 Google 来验证用户的配置文件,如 googleOAuthFacebook,甚至 Twitter

为了使用 passport,您必须安装以下软件包:npm i passport。在了解如何实现认证之前,您必须实现 userServiceuserModel

手动实现

在本节中,我们将使用 passport 手动实现认证,而不使用 Nest.js 软件包。

实施

为了配置 passport,需要配置三件事:

  • 认证策略

  • 应用程序中间件

  • 可选的会话

Passport 使用策略来验证请求,并且凭据的验证被委托给一些请求中的策略。

在使用 passport 之前,您必须配置策略,在这种情况下,我们将使用 passport-jwt 策略。

在任何其他操作之前,您必须安装适当的软件包:

  • npm i passport-jwt @types/passport-jwt

  • npm i jsonwebtoken @types/jsonwebtoken

认证模块

为了有一个可工作的示例,您必须实现一些模块,我们将从 AuthenticationModule 开始。AuthenticationModule 将使用 jwt 策略配置策略。为了配置策略,我们将扩展 passport-jwt 软件包提供的 Strategy 类。

策略

这是一个扩展 Strategy 类以配置并在 passport 中使用的策略的示例。

@Injectable()  
export default class JwtStrategy extends Strategy {  
   constructor(private readonly authenticationService: AuthenticationService) {  
       super({  
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),  
            passReqToCallback: true,  
            secretOrKey: 'secret'  
        }, async (req, payload, next) => {  
            return await this.verify(req, payload, next);  
        });  
        passport.use(this);  
    }  

   public async verify(req, payload, done) {  
       const isValid = await this.authenticationService.validateUser(payload);  
        if (!isValid) {  
           return done('Unauthorized', null);  
        } else {  
           return done(null, payload);  
        }  
   }  
}

构造函数允许您向扩展的 Strategy 类传递一些配置参数。在这种情况下,我们只使用了三个参数:

  • jwtFromRequest 选项接受一个函数,以从请求中提取令牌。在我们的情况下,我们使用 passport-jwt 软件包提供的 ExtractJwt.fromAuthHeaderAsBearerToken() 函数。此函数将从请求的标头中提取令牌,使用 Authorization 标头,并选择跟随 bearer 词的令牌。

  • passReqToCallback 参数接受一个布尔值,以便告诉您是否要在稍后看到的验证方法中获取 req

  • secretOrKey 参数接受一个字符串或缓冲区,以验证令牌签名。

还有其他参数可用于配置策略,但为了实现我们的认证,我们不需要它们。

此外,在传递不同的先前参数之后,我们传递了一个名为verify的回调函数。这个函数是异步的,其目的是验证传递的令牌以及从令牌获得的载荷是否有效。此函数执行我们的verify方法,该方法调用authenticationService以验证具有载荷作为参数的用户。

如果用户有效,我们返回载荷,否则我们返回一个错误以指示载荷无效。

身份验证服务

如前一节所示,为了验证从令牌中获取的载荷,调用AuthenticationService提供的validateUser方法。

事实上,该服务将实现另一种方法,以为已登录的用户生成令牌。该服务可以按照以下示例实现。

@Injectable()  
export class AuthenticationService {  
   constructor(private readonly userService: UserService) { }  

   createToken(email: string, ttl?: number) {  
        const expiresIn = ttl || 60 * 60;  
        const secretOrKey = 'secret';  
        const user = { email };  
        const token = jwt.sign(user, secretOrKey, { expiresIn });  
        return {  
            expires_in: expiresIn,  
            access_token: token,  
        };  
   }  

   async validateUser(payload: { email: string; password: string }): Promise<boolean> {  
        const user = await this.userService.findOne({  
            where: { email: payload.email }  
        });  
        return !!user;  
   }  
}

服务注入了UserService,以便使用传递给validateUser方法的载荷来查找用户。如果载荷中的电子邮件允许您找到用户,并且该用户具有有效的令牌,她可以继续身份验证过程。

为了为尝试登录的用户提供令牌,实现createToken方法,该方法以email和可选的ttl作为参数。ttl(生存时间)将配置令牌在一段时间内有效。ttl的值以秒为单位表示,我们在60 * 60中定义了默认值,这意味着 1 小时。

身份验证控制器

为了处理用户的身份验证,实现控制器并为登录端点提供处理程序。

@Controller()  
export class AuthenticationController {  
   constructor(  
        private readonly authenticationService: AuthenticationService,  
        private readonly userService: UserService) {}  

   @Post('login')  
   @HttpCode(HttpStatus.OK)  
   public async login(@Body() body: any, @Res() res): Promise<any> {  
       if (!body.email || !body.password) {  
           return res.status(HttpStatus.BAD_REQUEST).send('Missing email or password.');  
       }  

       const user = await this.userService.findOne({  
           where: {  
               email: body.email,  
                password: crypto.createHmac('sha256', body.password).digest('hex')  
           }  
       });  
       if (!user) {  
           return res.status(HttpStatus.NOT_FOUND).send('No user found with this email and password.');  
       }  

       const result = this.authenticationService.createToken(user.email);  
       return res.json(result);  
    }  
}

控制器提供了登录处理程序,可通过在POST /login路由上调用来访问。该方法的目的是验证用户提供的凭据,以便在数据库中找到他。如果找到用户,则创建适当的令牌,并将其作为响应返回,其中expiresIn值对应于我们先前定义的ttl。否则,请求将被拒绝。

模块

我们现在已经定义了我们的服务和策略,以配置 passport 并提供一些方法来创建令牌和验证载荷。让我们定义AuthenticationModule,它类似于以下示例。

@Module({})  
export class AuthenticationModule {  
   static forRoot(strategy?: 'jwt' | 'OAuth' | 'Facebook'): DynamicModule {  
       strategy = strategy ? strategy : 'jwt';  
        const strategyProvider = {  
            provide: 'Strategy',  
            useFactory: async (authenticationService: AuthenticationService) => {  
                const Strategy = (await import (`./passports/${strategy}.strategy`)).default;  
                return new Strategy(authenticationService);  
            },  
            inject: [AuthenticationService]  
       };  
        return {  
            module: AuthenticationModule,  
            imports: [UserModule],  
            controllers: [AuthenticationController],  
            providers: [AuthenticationService, strategyProvider],  
            exports: [strategyProvider]  
        };  
    }  
}

如您所见,该模块不是作为普通模块定义的,因此在@Module()装饰器中没有定义组件或控制器。事实上,该模块是一个动态模块。为了提供多种策略,我们可以在类上实现一个静态方法,以便在另一个模块中导入时调用它。这个forRoot方法以您想要使用的策略的名称作为参数,并将创建一个strategyProvider,以便添加到返回模块的组件列表中。该提供程序将实例化策略并将AuthenticationService作为依赖项提供。

让我们继续创建一些需要保护的东西,比如UserModule

用户模块

UserModule提供了一个服务、一个控制器和一个模型(请参阅 Sequelize 章节中的 User 模型)。我们在UserService中创建了一些方法,以便操作有关用户的数据。这些方法在UserController中使用,以向 API 的用户提供一些功能。

所有功能都不能被用户使用,或者在返回的数据中受到限制。

用户服务

让我们来看一个UserService的例子和一些方法,以便访问和操作数据。本部分描述的所有方法将在控制器中使用,其中一些受身份验证限制。

@Injectable()
export class UserService() {
    // The SequelizeInstance come from the DatabaseModule have a look to the Sequelize chapter
    constructor(@Inject('UserRepository') private readonly UserRepository: typeof User,
                @Inject('SequelizeInstance') private readonly sequelizeInstance) { }

    /* ... */
}

服务注入了我们在 Sequelize 章节中描述的UserRepository,以便访问模型和数据库中的数据存储。我们还注入了在 Sequelize 章节中描述的SequelizeInstance,以便使用事务。

UserService实现了findOne方法,以在options参数中传递条件查找用户。options参数可以如下所示:

{
    where: {
        email: 'some@email.test',
        firstName: 'someFirstName'
    }
}

使用这些条件,我们可以找到相应的用户。该方法将只返回一个结果。

@Injectable()
export class UserService() {
    /* ... */

    public async findOne(options?: object): Promise<User | null> {  
        return await this.UserRepository.findOne<User>(options);  
    }

    /* ... */
}

让我们实现findById方法,该方法以 ID 作为参数,以查找唯一的用户。

@Injectable()
export class UserService() {
    /* ... */

    public async findById(id: number): Promise<User | null> {  
        return await this.UserRepository.findById<User>(id);  
    }  

    /* ... */
}

然后我们需要一种方法,在数据库中创建一个新用户,传递符合IUser接口的用户。正如您所看到的,该方法使用this.sequelizeInstance.transaction事务,以避免在一切完成之前读取数据。该方法将参数传递给create函数,该函数是returning,以获取已创建的用户实例。

@Injectable()
export class UserService() {
    /* ... */

    public async create(user: IUser): Promise<User> {  
        return await this.sequelizeInstance.transaction(async transaction => {  
            return await this.UserRepository.create<User>(user, {  
                returning: true,  
                transaction,  
            });  
        });  
    }  

    /* ... */
}

当然,如果您可以创建用户,您也需要通过以下方法更新用户,遵循IUser接口。这个方法也将返回已更新的用户实例。

@Injectable()
export class UserService() {
    /* ... */

    public async update(id: number, newValue: IUser): Promise<User | null> {  
        return await this.sequelizeInstance.transaction(async transaction => {  
            let user = await this.UserRepository.findById<User>(id, { transaction });  
            if (!user) throw new Error('The user was not found.');  

            user = this._assign(user, newValue);  
            return await user.save({  
                returning: true,  
                transaction,  
            });  
        });  
    }  

    /* ... */
}

为了在所有方法中进行一轮,我们将实现delete方法,从数据库中完全删除用户。

@Injectable()
export class UserService() {
    /* ... */

    public async delete(id: number): Promise<void> {  
        return await this.sequelizeInstance.transaction(async transaction => {  
            return await this.UserRepository.destroy({  
                where: { id },  
                transaction,  
            });  
        });  
    }

    /* ... */
}

在所有先前的示例中,我们定义了一个完整的UserService,允许我们操作数据。我们有可能创建、读取、更新和删除用户。

用户模型

如果您想查看用户模型的实现,可以参考 Sequelize 章节。

用户控制器

现在我们已经创建了我们的服务和模型,我们需要实现控制器来处理来自客户端的所有请求。该控制器至少提供了一个创建、读取、更新和删除处理程序,应该像以下示例一样实现。

@Controller()  
export class UserController {  
   constructor(private readonly userService: UserService) { }

   /* ... */
}

控制器注入了UserService,以使用UserService中实现的方法。

提供一个GET users路由,允许访问数据库中的所有用户,您将看到我们不希望用户访问所有用户的数据,只希望用户访问自己的数据。这就是为什么我们使用了一个守卫,只允许用户访问自己的数据。

@Controller()  
export class UserController {  
    /* ... */

    @Get('users')  
    @UseGuards(CheckLoggedInUserGuard)
    public async index(@Res() res) {  
        const users = await this.userService.findAll();  
        return res.status(HttpStatus.OK).json(users);  
    }

    /* ... */
}

用户可以访问一个允许您创建新用户的路由。当然,如果您愿意,用户可以注册到已登录的应用程序中,我们必须允许那些没有限制的用户。

@Controller()  
export class UserController {  
    /* ... */

    @Post('users')  
    public async create(@Body() body: any, @Res() res) {  
       if (!body || (body && Object.keys(body).length === 0)) throw new Error('Missing some information.');  

        await this.userService.create(body);  
        return res.status(HttpStatus.CREATED).send();  
    }  

    /* ... */
}

我们还提供了一个GET users/:id路由,允许您通过 ID 获取用户。当然,已登录用户不应该能够访问另一个用户的数据,即使通过这个路由。该路由也受到守卫的保护,以允许用户访问自己而不是其他用户。

@Controller()  
export class UserController {  
    /* ... */

    @Get('users/:id')  
    @UseGuards(CheckLoggedInUserGuard)
    public async show(@Param() id: number, @Res() res) {  
       if (!id) throw new Error('Missing id.');  

        const user = await this.userService.findById(id);  
        return res.status(HttpStatus.OK).json(user);  
    }   

    /* ... */
}

用户可能想要更新自己的一些信息,这就是为什么我们通过以下PUT users/:id路由提供了一种更新用户的方式。这个路由也受到守卫的保护,以避免用户更新其他用户。

@Controller()  
export class UserController {  
    /* ... */
    @Put('users/:id')  
    @UseGuards(CheckLoggedInUserGuard)
    public async update(@Param() id: number, @Body() body: any, @Res() res) {  
       if (!id) throw new Error('Missing id.');  

        await this.userService.update(id, body);  
        return res.status(HttpStatus.OK).send();  
    }

使用删除来完成最后一个处理程序。这个路由也必须受到守卫的保护,以避免用户删除另一个用户。唯一能够被用户删除的用户是他自己。

    @Delete('users/:id')  
    @UseGuards(CheckLoggedInUserGuard)
    public async delete(@Param() id: number, @Res() res) {  
       if (!id) throw new Error('Missing id.');  

        await this.userService.delete(id);  
        return res.status(HttpStatus.OK).send();  
    }  
}

我们已经在这个控制器中实现了所有需要的方法。其中一些受到守卫的限制,以应用一些安全性,并防止用户操纵另一个用户的数据。

模块

为了完成UserModule的实现,我们当然需要设置模块。该模块包含一个服务、一个控制器和一个提供者,允许您注入用户模型并提供一种操作存储数据的方式。

@Module({  
    imports: [],  
    controllers: [UserController],  
    providers: [userProvider, UserService],
    exports: [UserService]  
})  
export class UserModule {}

该模块被导入到主AppModule中,就像AuthenticationModule一样,以便在应用程序中使用并可访问。

应用程序模块

AppModule导入了三个示例模块。

  • DatabaseModule访问 sequelize 实例并访问数据库。

  • AuthenticationModule允许您登录用户并使用适当的策略。

  • UserModule公开了一些可以由客户端请求的端点。

最后,该模块应该如以下示例所示。

@Module({  
   imports: [  
        DatabaseModule,  
        // Here we specify the strategy
        AuthenticationModule.forRoot('jwt'),  
        UserModule  
    ]
})  
export class AppModule implements NestModule {  
   public configure(consumer: MiddlewaresConsumer) {  
       consumer  
           .apply(AuthenticationMiddleware)  
           .with(strategy)  
           .forRoutes(  
               { path: '/users', method: RequestMethod.GET },  
                { path: '/users/:id', method: RequestMethod.GET },  
                { path: '/users/:id', method: RequestMethod.PUT },  
                { path: '/users/:id', method: RequestMethod.DELETE }  
           );  
    }  
}

如你在这个例子中所看到的,我们已经将AuthenticationMiddleware应用到了我们想要保护不被未登录用户访问的路由上。

这个中间件的目的是应用 passport 中间件passport.authenticate,它验证用户提供的令牌,并将请求存储在头部作为Authorization值。这个中间件将使用策略参数来对应应该应用的策略,对我们来说是strategy = 'jwt'

这个中间件应用于UserController的几乎所有路由,除了允许你创建新用户的POST /users

身份验证中间件

如前一节所示,我们已经应用了AuthenticationMiddleware,并且我们已经看到 passport 是用于验证用户的中间件。这个中间件将使用策略jwt执行passport.authenticate方法,使用一个回调函数来返回验证方法的结果。因此,我们可以接收对应于令牌的有效负载,或者在验证不起作用的情况下收到错误。

@Injectable()
export class AuthenticationMiddleware implements NestMiddleware {
    constructor(private userService: UserService) { }

    async resolve(strategy: string): Promise<ExpressMiddleware> {
        return async (req, res, next) => {
            return passport.authenticate(strategy, async (...args: any[]) => {
                const [,  payload, err] = args;
                if (err) {
                    return res.status(HttpStatus.BAD_REQUEST).send('Unable to authenticate the user.');
                }

                const user = await this.userService.findOne({ where: { email: payload.email }});
                req.user = user;
                return next();
            })(req, res, next);
        };
    }
}

如果身份验证成功,我们将能够将用户存储在请求req中,以便控制器或守卫使用。中间件实现了NestMiddleware接口,以实现解析函数。它还注入了UserService,以便找到已验证的用户。

使用守卫管理限制

Nest.js 带有一个守卫概念。这个可注入的守卫有一个单一的责任,就是确定请求是否需要由路由处理程序处理。

守卫用于实现canActivate接口的类,以实现canActivate方法。

守卫在每个中间件之后和任何管道之前执行。这样做的目的是将中间件的限制逻辑与守卫分开,并重新组织这个限制。

想象一下使用守卫来管理对特定路由的访问,并且你希望这个路由只能被已登录的用户访问。为此,我们实现了一个新的守卫,如果访问路由的用户与想要访问资源的用户相同,它必须返回true。使用这种类型的守卫,可以避免用户访问其他用户。

@Injectable()
export class CheckLoggedInUserGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const request = context.switchToHttp().getRequest();
        return Number(req.params.userId) === req.user.id;
    }
}

正如你所看到的,你可以从上下文中获取处理程序,该上下文对应于应用守卫的控制器上的路由处理程序。你还可以从请求参数中获取userId,并将其与请求中注册的已登录用户进行比较。如果想要访问数据的用户是相同的,那么他可以访问请求参数中的引用,否则他将收到403 Forbidden

要将守卫应用到路由处理程序,请参见以下示例。

@Controller()
@UseGuards(CheckLoggedInUserGuard)  
export class UserController {/*...*/}

现在我们已经保护了所有的用户控制器的路由处理程序,它们都是可访问的,除了delete,因为用户必须是admin才能访问。如果用户没有适当的角色,他们将收到403 Forbidden的响应。

Nest.js passport 包

@nestjs/passport包是一个可扩展的包,允许你在 Nest.js 中使用 passport 的任何策略。如前一节所示,可以手动实现身份验证,但如果想要更快地实现并包装策略,那么就使用这个好的包。

在本节中,你将看到使用jwt的包的用法,就像前一节所示的那样。要使用它,你必须安装以下包:

npm install --save @nestjs/passport passport passport-jwt jsonwebtoken

要使用这个包,你将有可能使用与前一节中实现的完全相同的AuthenticationService,但记得遵循下面的代码示例。

@Injectable()  
export class AuthenticationService {  
   constructor(private readonly userService: UserService) { }  

   createToken(email: string, ttl?: number) {  
        const expiresIn = ttl || 60 * 60;  
        const secretOrKey = 'secret';  
        const user = { email };  
        const token = jwt.sign(user, secretOrKey, { expiresIn });  
        return {  
            expires_in: expiresIn,  
            access_token: token,  
        };  
   }  

   async validateUser(payload: { email: string; password: string }): Promise<boolean> {  
        const user = await this.userService.findOne({  
            where: { email: payload.email }  
        });  
        return !!user;  
   }  
}

要实例化 jwt 策略,你还需要实现 JwtStrategy,但现在你只需要传递选项,因为 passport 被包装在这个包中,并且会在幕后自动将策略应用于 passport。

@Injectable()
export default class JwtStrategy extends PassportStrategy(Strategy) {  
   constructor(private readonly authenticationService: AuthenticationService) {  
       super({  
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),  
            passReqToCallback: true,  
            secretOrKey: 'secret'  
        });
    }  

   public async validate(req, payload, done) {  
       const isValid = await this.authenticationService.validateUser(payload);  
        if (!isValid) {  
           return done('Unauthorized', null);  
        } else {  
           return done(null, payload);  
        }  
   }  
}

正如你所看到的,在这个新的 JwtStrategy 实现中,你不再需要实现回调。这是因为现在你扩展了 PassportStrategy(Strategy),其中 Strategy 是从 passport-jwt 库中导入的成员。此外,PassportStrategy 是一个混合类,将调用我们实现并根据这个混合类的抽象成员命名的 validate 方法。该方法将被策略调用作为有效载荷的验证方法。

该包提供的另一个功能是 AuthGuard,它可以与 @UseGuards(AuthGuard('jwt')) 一起使用,以在特定控制器方法上启用身份验证,而不是使用我们在上一节中实现的中间件。

AuthGuard 接受策略名称作为参数,我们的示例中是 jwt,还可以接受遵循 AuthGuardOptions 接口的其他一些参数。该接口定义了三个可用的选项:

    • callback 作为允许你实现自己逻辑的函数
    • property 作为一个字符串,用于定义要添加到请求中并附加到经过身份验证的用户的属性的名称
  • 你还看到了新的 @nestjs/passport 包,它允许你以更快的方式实现一些类,如 AuthenticationServiceJwtStrategy,并能够使用该包提供的 AuthGuard 在任何控制器方法上验证任何用户。

默认情况下,session 被设置为 false,property 被设置为 user。默认情况下,回调将返回 userUnauthorizedException。就是这样,现在你可以在任何控制器方法上验证用户并从请求中获取用户。

你唯一需要做的就是创建以下示例中的 AuthModule

@Module({
  imports: [UserModule],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

正如你所看到的,现在不需要创建提供者来实例化策略,因为它现在被包装在这个包中。

摘要

在本章中,你已经学会了什么是 passport 以及配置 passport 不同部分的策略,以便验证用户并将其存储到请求中。你还学会了如何实现不同的模块,AuthenticationModuleUserModule,以便用户登录并提供一些用户可访问的端点。当然,我们已经通过 AuthenticationMiddlewareCheckLoggedInUserGuard 限制了对一些数据的访问,以提供更多安全性。

在下一章中,你将学习关于依赖注入模式的内容。

  • session 作为布尔值

第四章:Nest.js 的依赖注入系统

本章概述了依赖注入(DI)模式,这是今天最大的框架经常使用的一种方式。这是一种保持代码清晰且易于使用的方法。通过使用此模式,您最终会得到更少耦合的组件和更多可重用的组件,这有助于加快开发过程时间。

在这里,我们将研究在模式存在之前使用注入的方法,以及注入如何随着时间的推移而改变,以使用 TypeScript 和装饰器的现代方法进行 Nest.js 注入。您还将看到显示此类模式优势的代码片段,以及框架提供的模块。

Nest.js 在架构上基于 Angular,并用于创建可测试、可扩展、松耦合和易于维护的应用程序。与 Angular 一样,Nest.js 有自己的依赖注入系统,这是框架的core的一部分,这意味着 Nest.js 不太依赖第三方库。

依赖注入概述

Typescript 1.5引入装饰器的概念以来,您可以使用装饰器在不同对象或属性上提供的添加元数据进行元编程,例如classfunctionfunction parametersclass property。元编程是使用描述对象的元数据编写一些代码或程序的能力。这种类型的程序允许您使用其自身的元数据修改程序的功能。在我们的情况下,这些元数据对我们很有兴趣,因为它有助于将某些对象注入到另一个对象中,其名称为依赖注入。

通过使用装饰器,您可以在与这些装饰器相关联的任何对象或属性上添加元数据。例如,这将定义接受装饰器的对象的类型,但它还可以定义函数所需的所有参数,这些参数在其元数据中描述。要获取或定义任何对象上的元数据,您还可以使用reflect-metadata库来操纵它们。

为什么使用依赖注入

使用依赖注入的真正好处在于,依赖对象与其依赖项之间的耦合度更低。通过提供注入器系统的框架,您可以管理对象而无需考虑它们的实例化,因为这由注入器来管理,后者旨在解决每个依赖对象的依赖关系。

这意味着更容易编写测试和模拟依赖项,这些测试更清晰和更易读。

没有依赖注入的情况下如何运作

让我们想象一个需要注入UserServiceAuthenticationService

这里是UserService

export class UserService() {
    private users: Array<User> = [{
        id: 1,
        email: 'userService1@email.com',
        password: 'pass'
    ]};

    public findOne({ where }: any): Promise<User> {
        return this.users
        .filter(u => {
            return u.email === where.email &&
            u.password === where.password;
        });
    }
}

还有AuthenticationService,它实例化所需的UserService

export class AuthenticationService {
    public userService: UserService;

    constructor() {
        this.userService = new UserService();
    }

    async validateAUser(payload: { email: string; password: string }): Promise<boolean> {
        const user = await this.userService.findOne({
            where: payload
        });
        return !!user;
    }
}

const authenticationService = new AuthenticationService();

正如您所看到的,您必须在类本身中管理所有相关的依赖项,以便在AuthenticationService内部使用。

这种方法的缺点主要是AuthenticationService的不灵活性。如果要测试此服务,您必须考虑其自身的隐藏依赖项,当然,您不能在不同的类之间共享任何服务。

使用手动依赖注入的工作原理

现在让我们看看如何使用先前的UserService通过构造函数传递依赖项。

// Rewritted AuthenticationService
export class AuthenticationService {
    /* 
 Declare at the same time the public 
 properties belongs to the class
 */
    constructor(public userService: UserService) { }
}

// Now you can instanciate the AutheticationService like that
const userService = new UserService();
const authenticationService = new AuthenticationService(userService);

您可以轻松地通过所有对象共享userService实例,而不再是AuthenticationService必须创建UserService实例。

这使生活变得更容易,因为注入器系统将允许您执行所有这些操作,而无需实例化依赖项。让我们在下一节中使用前面的类来看看这一点。

依赖注入模式今天

今天,要使用依赖注入,你只需要使用 Typescript 提供的装饰器系统,并由你想要使用的框架实现。在我们的案例中,正如你将在工具章节中看到的那样,Nest.js 提供了一些装饰器,它们几乎什么都不做,只是在它们将被使用的对象或属性上添加一些元数据。

这些元数据将帮助框架意识到这些对象可以被操作,注入所需的依赖关系。

以下是@Injectable()装饰器的使用示例:

@Injectable()
export class UserService { /*...*/ }

@Injectable()
export class AuthenticationService {
    constructor(private userService: UserService) { }
}

这个装饰器将被转译,并且将向其添加一些元数据。这意味着在类上使用装饰器后,你可以访问design:paramtypes,这允许注入器知道依赖于AuthenticationService的参数的类型。

通常,如果你想创建自己的类装饰器,这个装饰器将以target作为参数,表示你的类的type。在前面的例子中,AuthenticationService的类型就是AuthenticationService本身。这个自定义类装饰器的目的将是将目标注册到服务的Map中。

export Component = () => {
    return (target: Type<object>) => {
        CustomInjector.set(target);
    };
}

当然,你已经看到了如何将服务注册到服务的 Map 中,那么让我们看看这可能是一个自定义注入器。这个注入器的目的将是将所有服务注册到 Map 中,并解决对象的所有依赖关系。

export const CustomInjector = new class {
  protected services: Map<string, Type<any>> = new Map<string, Type<any>>();

  resolve<T>(target: Type<any>): T {
    const tokens = Reflect.getMetadata('design:paramtypes', target) || [];
    const injections = tokens.map(token => CustomInjector.resolve<any>(token));
    return new target(/*...*/injections);
  }

  set(target: Type<any>) {
    this.services.set(target.name, target);
  }
};

因此,如果你想实例化我们的AuthenticationService,它依赖于超级UserService类,你应该调用注入器来解决依赖关系,并返回所需对象的实例。

在下面的例子中,我们将通过注入器解决UserService,并将其传递到AuthenticationService的构造函数中,以便能够实例化它。

const authenticationService = CustomInjector.resolve<AuthenticationService>(AuthenticationService);
const isValid = authenticationService.validateUser(/* payload */);

Nest.js 依赖注入

@nestjs/common中,你可以访问框架提供的装饰器,其中之一就是@Module()装饰器。这个装饰器是构建所有模块并在它们之间使用 Nest.js 依赖注入系统的主要装饰器。

你的应用程序将至少有一个模块,即主模块。在小型应用程序的情况下,应用程序可以只使用一个模块(主模块)。然而,随着应用程序的增长,你将不得不创建多个模块来为主模块安排应用程序。

从主模块中,Nest 将知道你已经导入的所有相关模块,然后创建应用程序树来管理所有的依赖注入和模块的范围。

为了做到这一点,@Module()装饰器遵循ModuleMetadata接口,该接口定义了允许配置模块的属性。

export interface ModuleMetadata {  
    imports?: any[];  
    providers?: any[];  
    controllers?: any[];  
    exports?: any[];
    modules?: any[]; // this one is deprecated.
}

要定义一个模块,你必须注册所有存储在providers中的服务,这些服务将由 Nest.js 的injector实例化,以及可以注入提供者的controllers,这些提供者是通过exports属性注册到模块中的服务,或者由其他模块导出的服务。在这种情况下,这些服务必须在imports中注册。

如果一个模块没有导出可注入的内容,并且导出模块没有被导入到使用外部服务的相关模块中,那么就无法访问另一个模块中的可注入内容。

Nest.js 如何创建依赖注入树?

在前一节中,我们谈到了主模块,通常称为AppModule,它用于从NestFactory.create创建应用程序。从这里,Nest.js 将不得不注册模块本身,并且还将遍历导入到主模块的每个模块。

Nest.js 然后会为整个应用程序创建一个container,其中包含整个应用程序的moduleglobalModuledynamicModuleMetadata

在创建了容器之后,它将初始化应用程序,并在初始化期间实例化一个 InstanceLoader 和一个 DependenciesScanner -> scanner.ts,通过它,Nest.js 将有可能扫描与每个模块和元数据相关的所有模块。它这样做是为了解决所有的依赖关系,并生成所有模块和服务的实例及其自己的注入。

如果你想了解引擎的细节,我们建议你深入了解两个类:InstanceLoaderDependenciesScanner

为了更好地理解这是如何工作的,看一个例子。

想象一下,你有三个模块:

  • ApplicationModule

  • AuthenticationModule

  • UserModule

应用程序将从 ApplicationModule 创建:

@Module({
    imports: [UserModule, AuthenticationModule]
})
export class ApplicationModule {/*...*/}

这导入了 AuthenticationModule

@Module({
    imports: [UserModule],
    providers: [AuthenticationService]
})
export class AuthenticationModule {/*...*/}

@Injectable()
export class AuthenticationService {
    constructor(private userService: UserService) {}
}

以及 UserModule

@Module({
    providers: [UserService],
    exports: [UserService]
})
export class UserModule {/*...*/}

@Injectable()
export class UserService {/*...*/}

在这种情况下,AuthenticationModule 必须导入 UserModule,后者导出 UserService

我们现在已经构建了应用程序的架构模块,并且需要创建应用程序,它将允许解决所有的依赖关系。

const app = await NestFactory.create(ApplicationModule);

基本上,当你创建应用程序时,Nest.js 将:

  • 扫描模块。

  • 存储模块和一个空的作用域数组(用于主模块)。然后将作用域填充为导入此扫描模块的模块。

  • 查看通过 modules 元数据相关的模块。

  • 扫描模块的依赖项作为服务、控制器、相关模块和导出项,将它们存储在模块中。

  • 将所有全局模块绑定到每个模块中的相关模块。

  • 通过解析原型创建所有的依赖项,为每个依赖项创建一个实例。对于具有自己依赖项的依赖项,Nest.js 将以相同的方式解析它们,并将其包含在前一级中。

全局模块呢?

Nest.js 还提供了一个 @Global() 装饰器,允许 Nest 将它们存储在全局模块的 Set 中,并将其添加到相关模块的 Set 中。

这种类型的模块将使用 __globalModule__ 元数据键进行注册,并添加到容器的 globalModule 集合中。然后它们将被添加到相关模块的 Set 中。有了全局模块,你可以允许将模块中的组件注入到另一个模块中,而无需将其导入到目标模块中。这避免了将一个可能被所有模块使用的模块导入到所有模块中。

这是一个例子:

@Module({
    imports: [DatabaseModule, UserModule]
})
export class ApplicationModule {/*...*/}

@Global()
@Module({
    providers: [databaseProvider],
    exports: [databaseProvider]
})
export class DatabaseModule {/*...*/}

@Module({
    providers: [UserService],
    exports: [UserService]
})
export class UserModule {/*...*/}

@Injectable()
export class UserService {
    // SequelizeInstance is provided by the DatabaseModule store as a global module
    constructor(@Inject('SequelizeInstance') private readonly sequelizeInstance) {}
}

有了之前的所有信息,你现在应该对 Nest.js 依赖注入的机制很熟悉,并且对它们如何一起工作有了更好的理解。

Nest.js 和 Angular DI 之间的区别

即使 Nest.js 在很大程度上基于 Angular,它们之间存在一个重大区别。在 Angular 中,每个服务都是单例,这与 Nest.js 相同,但是可以要求 Angular 提供服务的新实例。在 Angular 中,你可以使用 @Injectable() 装饰器的 providers 属性来注册模块中的提供者的新实例,并且仅对该组件可用。这对于避免通过不同组件覆盖某些属性非常有用。

总结

因此,总结一下,我们在本章中看到了如何在不使用依赖注入的情况下,对象是多么不灵活和难以测试。此外,我们还了解了如何实现依赖项注入的方法的演变,首先是通过将依赖项实现到依赖项中,然后通过手动将它们传递到构造函数来改变方法,最终到达注入器系统。然后通过解析树自动在构造函数中解析依赖项,这就是 Nest.js 如何使用这种模式。

在下一章中,我们将看到 Nest.js 如何使用 TypeORM,这是一个与多种不同关系数据库一起工作的对象关系映射(ORM)。

第五章:TypeORM

几乎每次在现实世界中使用 Nest.js 时,您都需要某种持久性来保存数据。也就是说,您需要将 Nest.js 应用程序接收到的数据保存在某个地方,并且您需要从某个地方读取数据,以便随后将该数据作为响应传递给 Nest.js 应用程序接收到的请求。

大多数情况下,“某个地方”将是一个数据库。

TypeORM 是一个与多种不同关系数据库一起工作的对象关系映射(ORM)。对象关系映射是一个工具,用于在对象(例如“Entry”或“Comment”,因为我们正在构建一个博客)和数据库中的表之间进行转换。

这种转换的结果是一个实体(称为数据传输对象),它知道如何从数据库中读取数据到内存(这样您就可以将数据作为请求的响应使用),以及如何从内存写入数据库(这样您就能够存储数据以备后用)。

TypeORM 在概念上类似于 Sequelize。TypeORM 也是用 TypeScript 编写的,并且广泛使用装饰器,因此它非常适合 Nest.js 项目。

我们显然将专注于将 TypeORM 与 Nest.js 一起使用,但 TypeORM 也可以在浏览器和服务器端使用,使用传统的 JavaScript 以及 TypeScript。

TypeORM 允许您同时使用数据映射器模式和活动记录模式。我们将专注于活动记录模式,因为它大大减少了在典型 Nest.js 架构上使用所需的样板代码量,就像本书中所解释的那样。

TypeORM 也可以与 MongoDB 一起工作,不过在这种情况下,使用专门的 NoSQL ORM,如 Mongoose,是更常见的方法。

使用哪种数据库

TypeORM 支持以下数据库:

  • MySQL

  • MariaDB

  • PostgreSQL

  • MS SQL Server

  • sql.js

  • MongoDB

  • Oracle(实验性)

考虑到在本书中我们已经使用 Sequelize 和 Mongoose 分别使用 PostgreSQL 和 MongoDB,我们决定使用 TypeORM 与 MariaDB。

关于 MariaDB

MariaDB 是一个由 MySQL 的一些原始开发人员领导的开源、社区驱动的项目。它是从 Oracle 收购后保持其自由和开放性的 GNU 通用公共许可证下的 MySQL 分支。

该项目的最初想法是作为 MySQL 的一个可替代品。这在 5.5 版本之前基本上是正确的,而 MariaDB 保持了与 MySQL 相同的版本号。

尽管如此,从 10.0 版本开始,较新的版本略微偏离了这种方法。不过,MariaDB 仍然专注于与 MySQL 高度兼容,并共享相同的 API。

入门

当然,TypeORM 作为一个 npm 包进行分发。您需要运行npm install typeorm @nestjs/typeorm

您还需要一个 TypeORM 数据库驱动程序;在这种情况下,我们将使用npm install mysql安装 MySQL/MariaDB。

TypeORM 还依赖于reflect-metadata,但幸运的是,我们之前已经安装了它,因为 Nest.js 也依赖于它,所以我们无需做其他事情。请记住,如果您在 Nest.js 上下文之外使用 TypeORM,您还需要安装这个依赖。

注意: 如果您还没有安装 Node.js,现在安装是一个好主意:npm install --save-dev @types/node

启动数据库

为了连接到数据库,我们将使用 Docker Compose,使用官方的 MariaDB Docker 镜像来设置我们的本地开发环境。我们将指向latest Docker 镜像标签,这在撰写本文时对应于版本 10.2.14。

version: '3'

volumes:
  # for persistence between restarts
  mariadb_data:

services:
  mariadb:
    image: mariadb:latest
    restart: always
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: nestbook
      MYSQL_USER: nest
      MYSQL_PASSWORD: nest
    volumes:
        - mariadb_data:/var/lib/mysql

  api:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - NODE_ENV=development
    depends_on:
      - mariadb
    links:
      - mariadb
    environment:
      PORT: 3000
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    command: >
      npm run start:dev

连接到数据库

现在我们有了一个连接 TypeORM 的数据库,让我们配置连接。

我们有几种配置 TypeORM 的方式。最直接的一种是在项目根文件夹中创建一个ormconfig.json文件,这对于入门非常有用。这个文件将在启动时被 TypeORM 自动抓取。

这是一个适合我们用例的示例配置文件(即使用 Docker Compose 与之前提出的配置)。

ormconfig.json

{
  "type": "mariadb",
  "host": "mariadb",
  "port": 3306,
  "username": "nest",
  "password": "nest",
  "database": "nestbook",
  "synchronize": true,
  "entities": ["src/**/*.entity.ts"]
}

关于配置文件的一些说明:

  • 属性hostportusernamepassworddatabase需要与docker-compose.yml文件中之前指定的属性匹配;否则,TypeORM 将无法连接到 MariaDB Docker 镜像。

  • synchronize属性告诉 TypeORM 在应用程序启动时是否创建或更新数据库模式,以便模式与代码中声明的实体匹配。将此属性设置为true很容易导致数据丢失,所以在启用此属性之前,请确保你知道你在做什么

初始化 TypeORM

现在数据库正在运行,并且你能够成功地建立起它与我们的 Nest.js 应用之间的连接,我们需要指示 Nest.js 使用 TypeORM 作为一个模块。

由于我们之前安装的@nest/typeorm包,所以在我们的 Nest.js 应用程序中使用 TypeORM 就像在主应用程序模块(可能是app.module.ts文件)中导入TypeOrmModule一样简单。

import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot(),
    ...
  ]
})

export class AppModule {}

建模我们的数据

使用 ORM 最好的一点可能是,你可以利用它们提供的建模抽象:基本上,它们允许我们思考我们的数据,并用属性(包括类型和关系)来塑造它,生成我们可以直接使用和操作的“对象类型”(并将它们连接到数据库表)。

这个抽象层可以让你摆脱编写特定于数据库的代码,比如查询、连接等。很多人喜欢不必为选择和类似的事情而苦苦挣扎;所以这个抽象层非常方便。

我们的第一个实体

在使用 TypeORM 时,这些对象抽象被称为实体

实体基本上是映射到数据库表的类。

说到这里,让我们创建我们的第一个实体,我们将其命名为Entry。我们将使用这个实体来存储博客的条目(帖子)。我们将在src/entries/entry.entity.ts创建一个新文件;这样 TypeORM 就能够找到这个实体文件,因为在我们的配置中我们指定了实体文件将遵循src/**/*.entity.ts文件命名约定。

import { Entity } from 'typeorm';

@Entity()
export class Entry {}

@Entity()装饰器来自typeorm npm 包,用于标记Entry类为一个实体。这样,TypeORM 就会知道它需要在我们的数据库中为这种对象创建一个表。

Entry实体还有点太简单了:我们还没有为它定义一个属性。我们可能需要像标题、正文、图片和日期这样的东西来记录博客条目,对吧?让我们来做吧!

import { Entity, Column } from 'typeorm';

@Entity()
export class Entry {
  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @Column() created_at: Date;
}

不错!我们为实体定义的每个属性都标有@Column装饰器。再次,这个装饰器告诉 TypeORM 如何处理属性:在这种情况下,我们要求每个属性都存储在数据库的一列中。

遗憾的是,这个实体将无法使用这段代码。这是因为每个实体都需要至少一个主列,而我们没有将任何列标记为主列。

我们最好为每个条目创建一个id属性,并将其存储在主列上。

import { Entity, Column, PrimaryColumn } from 'typeorm';

@Entity()
export class Entry {
  @PrimaryColumn() id: number;

  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @Column() created_at: Date;
}

啊,好多了!我们的第一个实体现在可以工作了。让我们来使用它!

使用我们的模型

当需要将请求连接到数据模型时,在 Nest.js 中的典型方法是构建专门的服务,这些服务作为与每个模型的“接触点”,并构建控制器,将服务与到达 API 的请求连接起来。让我们在以下步骤中遵循模型 -> 服务 -> 控制器的方法。

服务

在典型的 Nest.js 架构中,应用程序的重要工作是由服务完成的。为了遵循这种模式,创建一个新的EntriesService,用它来与Entry实体交互。

所以,让我们在这里创建一个新文件:src/entries/entries.service.ts

import { Component } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { Entry } from './entry.entity';

@Injectable()
export class EntriesService {
  constructor(
    // we create a repository for the Entry entity
    // and then we inject it as a dependency in the service
    @InjectRepository(Entry) private readonly entry: Repository<Entry>
  ) {}

  // this method retrieves all entries
  findAll() {
    return this.entry.find();
  }

  // this method retrieves only one entry, by entry ID
  findOneById(id: number) {
    return this.entry.findOneById(id);
  }

  // this method saves an entry in the database
  create(newEntry: Entry) {
    this.entry.save(newEntry);
  }
}

服务的最重要部分是使用Repository<Entry>创建 TypeORM 存储库,然后在构造函数中使用@InjectRepository(Entry)进行注入。

顺便说一句,如果你在想,当处理 ORM 时,存储库可能是最常用的设计模式,因为它允许你将数据库操作抽象为对象集合。

回到最新的服务代码,一旦你创建并注入了 Entry 存储库,就可以使用它从数据库中.find().save()条目,以及其他操作。当我们为实体创建存储库时,这些有用的方法会被添加进来。

既然我们已经处理了数据模型和服务,现在让我们为最后一个链接编写代码:控制器。

控制器

让我们为 Entry 模型创建一个控制器,通过 RESTful API 将其暴露给外部世界。代码非常简单,你可以看到。

继续,在以下位置创建一个新文件:src/entries/entries.controller.ts

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

import { EntriesService } from './entry.service';

@Controller('entries')
export class EntriesController {
  constructor(private readonly entriesSrv: EntriesService) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }

  @Get(':entryId')
  findOneById(@Param('entryId') entryId) {
    return this.entriesSrv.findOneById(entryId);
  }

  @Post()
  create(@Body() entry) {
    return this.entriesSrv.create(entry);
  }
}

和往常一样,我们使用 Nest.js 依赖注入使EntryServiceEntryController中可用。

构建一个新的模块

我们新实体端点工作的最后一步是在应用模块中包含实体、服务和控制器。我们不会直接这样做,而是遵循“分离模块”的方法,为我们的条目创建一个新模块,在那里导入所有必要的部分,然后在应用模块中导入整个模块。

因此,让我们创建一个名为:src/entries/entries.module.ts的新文件。

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { Entry } from './entry.entity';
import { EntriesController } from './entry.controller';
import { EntriesService } from './entry.service';

@Module({
  imports: [TypeOrmModule.forFeature([Entry])],
  controllers: [EntriesController],
  components: [EntriesService],
})
export class EntriesModule {}

还记得当我们在本章的最初步骤中在AppModule中包含了TypeOrmModule吗?我们在那里使用了TypeOrmModule.forRoot()公式。然而,在这里我们使用了不同的公式:TypeOrmModule.forFeature()

Nest.js TypeORM 实现中的这种区别允许我们在不同的模块中分离不同的功能(“特性”)。这样你就可以根据本书的架构章节中提出的一些想法和最佳实践来调整你的代码。

无论如何,让我们将新的EntriesModule导入到AppModule中。如果忽略了这一步,你的主应用模块将不会意识到EntriesModule的存在,你的应用将无法正常工作。

src/app.module.ts

import { TypeOrmModule } from '@nestjs/typeorm';
import { EntriesModule } from './entries/entries.module';

@Module({
  imports: [
    TypeOrmModule.forRoot(),
    EntriesModule,
    ...
  ]
})

export class AppModule {}

就是这样!现在你可以向/entities发送请求,端点将调用数据库的写入和读取操作。

是时候让我们的数据库试试了!我们将向之前链接到数据库的端点发送一些请求,看看是否一切都按预期工作。

我们将从向/entries端点发送 GET 请求开始。显然,由于我们还没有创建任何条目,我们应该收到一个空数组作为响应。

> GET /entries HTTP/1.1
> Host: localhost:3000
< HTTP/1.1 200 OK

[]

让我们创建一个新的条目。

> GET /entries HTTP/1.1
> Host: localhost:3000
| {
|   "id": 1,
|   "title": "This is our first post",
|   "body": "Bla bla bla bla bla",
|   "image": "http://lorempixel.com/400",
|   "created_at": "2018-04-15T17:42:13.911Z"
| }

< HTTP/1.1 201 Created

成功!让我们通过 ID 检索新条目。

> GET /entries/1 HTTP/1.1
> Host: localhost:3000
< HTTP/1.1 200 OK

{
  "id": 1,
  "title": "This is our first post",
  "body": "Bla bla bla bla bla",
  "image": "http://lorempixel.com/400",
  "created_at": "2018-04-15T17:42:13.911Z"
}

是的!我们之前的 POST 请求触发了数据库中的写入,现在这个最后的 GET 请求触发了对数据库的读取,并返回先前保存的数据!

现在让我们再次尝试检索所有条目。

> GET /entries HTTP/1.1
> Host: localhost:3000
< HTTP/1.1 200 OK

[{
  "id": 1,
  "title": "This is our first post",
  "body": "Bla bla bla bla bla",
  "image": "http://lorempixel.com/400",
  "created_at": "2018-04-15T17:42:13.911Z"
}]

我们刚刚确认,对/entries端点的请求成功执行了数据库的读写操作。这意味着我们的 Nest.js 应用现在可以使用,因为几乎任何服务器应用程序的基本功能(即存储数据并根据需要检索数据)都正常工作。

改进我们的模型

尽管我们现在通过实体从数据库中读取和写入数据,但我们只编写了一个基本的初始实现;我们应该审查我们的代码,看看有什么可以改进的地方。

现在让我们回到实体文件src/entries/entry.entity.ts,看看我们可以做出什么样的改进。

自动生成的 ID

所有的数据库条目都需要有一个唯一的 ID。目前,我们只是依赖于客户端在创建实体时(发送 POST 请求时)发送的 ID,但这并不理想。

任何服务器端应用程序都将连接到多个客户端,所有这些客户端都无法知道哪些 ID 已经在使用,因此他们无法生成并发送每个 POST 请求的唯一 ID。

TypeORM 提供了几种为实体生成唯一 ID 的方法。第一种是使用@PrimaryGeneratedColumn()装饰器。通过使用它,您不再需要在 POST 请求的主体中包含 ID,也不需要在保存条目之前手动生成 ID。相反,每当您要求将新条目保存到数据库时,TypeORM 会自动为其生成 ID。

我们的代码看起来像下面这样:

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn() id: number;

  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @Column() created_at: Date;
}

值得一提的是,这些唯一的 ID 将以顺序方式生成,这意味着每个 ID 将比数据库中已有的最高 ID 高一个数字(生成新 ID 的确切方法将取决于数据库类型)。

TypeORM 还可以更进一步:如果将"uuid"参数传递给@PrimaryGeneratedColumn()装饰器,生成的值将看起来像一串随机的字母和数字,带有一些破折号,确保它们是唯一的(至少相对唯一)。

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @Column() created_at: Date;
}

还要记得将id的类型从number更改为string

条目是何时创建的?

在原始实体定义中,还预期从客户端接收created_at字段。然而,我们可以通过一些更多的 TypeORM 魔术装饰器轻松改进这一点。

让我们使用@CreateDateColumn()装饰器为每个条目动态生成插入日期。换句话说,您不需要在保存条目之前从客户端设置日期或手动创建日期。

让我们更新实体:

import {
  Entity,
  Column,
  CreateDateColumn,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @CreateDateColumn() created_at: Date;
}

不错,是吗?还想知道条目上次修改是什么时候,以及对其进行了多少次修订?同样,TypeORM 使这两者都很容易实现,并且不需要我们额外的代码。

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
} from 'typeorm';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column() body: string;

  @Column() image: string;

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

我们的实体现在将自动为我们处理修改日期,以及每次保存操作时的修订号。您可以跟踪对实体的每个实例所做的更改,而无需实现一行代码!

列类型

在我们的实体中使用装饰器定义列时,如上所述,TypeORM 将从使用的属性类型推断数据库列的类型。这基本上意味着当 TypeORM 找到以下行时

@Column() title: string;

这将string属性类型映射到varchar数据库列类型。

这通常会很好地工作,但在某些情况下,我们可能需要更明确地指定要在数据库中创建的列的类型。幸运的是,TypeORM 允许使用非常少的开销来实现这种自定义行为。

要自定义列类型,请将所需类型作为字符串参数传递给@Column()装饰器。一个具体的例子是:

@Column('text') body: string;

可以使用的确切列类型取决于您使用的数据库类型。

mysql / mariadb的列类型

inttinyintsmallintmediumintbigintfloatdoubledecdecimalnumericdatedatetimetimestamptimeyearcharvarcharnvarchartexttinytextmediumtextbloblongtexttinyblobmediumbloblongblobenumjsonbinarygeometrypointlinestringpolygonmultipointmultilinestringmultipolygongeometrycollection

postgres的列类型

intint2int4int8smallintintegerbigintdecimalnumericrealfloatfloat4float8double precisionmoneycharacter varyingvarcharcharacterchartextcitexthstorebyteabitvarbitbit varyingtimetztimestamptztimestamptimestamp without time zonetimestamp with time zonedatetimetime without time zonetime with time zoneintervalboolbooleanenumpointlinelsegboxpathpolygoncirclecidrinetmacaddrtsvectortsqueryuuidxmljsonjsonbint4rangeint8rangenumrangetsrangetstzrangedaterange

sqlite / cordova / react-native的列类型

intint2int8integertinyintsmallintmediumintbigintdecimalnumericfloatdoublerealdouble precisiondatetimevarying charactercharacternative charactervarcharncharnvarchar2unsigned big intbooleanblobtextclobdate

mssql的列类型

intbigintbitdecimalmoneynumericsmallintsmallmoneytinyintfloatrealdatedatetime2datetimedatetimeoffsetsmalldatetimetimecharvarchartextncharnvarcharntextbinaryimagevarbinaryhierarchyidsql_varianttimestampuniqueidentifierxmlgeometrygeography

oracle的列类型

charncharnvarchar2varchar2longrawlong rawnumbernumericfloatdecdecimalintegerintsmallintrealdouble precisiondatetimestamptimestamp with time zonetimestamp with local time zoneinterval year to monthinterval day to secondbfileblobclobnclobrowidurowid

如果你还没有准备好承诺使用特定的数据库类型,并且希望为将来保持选择的开放性,那么使用不是每个数据库都可用的类型可能不是最好的主意。

SQL 中的 NoSQL

TypeORM 还有一个最后的绝招:simple-json列类型,可以在每个支持的数据库中使用。使用它,你可以直接在关系数据库列中保存普通的 JavaScript 对象。是的,令人惊叹!

让我们在实体中使用一个新的author属性。

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
} from 'typeorm';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

simple-json列类型允许您直接存储甚至复杂的 JSON 树,而无需首先定义一个模型。在您欣赏比传统的关系数据库结构更灵活的情况下,这可能会派上用场。

数据模型之间的关系

如果您一直跟着本章节,那么您将有一种通过 API 将新的博客条目保存到数据库中,然后再读取它们的方法。

接下来是创建第二个实体来处理每个博客条目中的评论,然后以这样的方式创建条目和评论之间的关系,以便一个博客条目可以有属于它的多个评论。

然后创建Comments实体。

src/comments/comment.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
} from 'typeorm';

@Entity()
export class Comment {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column('text') body: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

您可能已经注意到Comment实体与Entry实体非常相似。

接下来的步骤将是在条目和评论之间创建一个“一对多”的关系。为此,在Entry实体中包含一个新的属性,使用@OneToMany()装饰器。

src/entries/entry.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
  OneToMany,
} from 'typeorm';

import { Comment } from '../comments/comment.entity';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @OneToMany(type => Comment, comment => comment.id)
  comments: Comment[];

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

“一对多”关系必须是双向的,因此您需要在Comment实体中添加一个反向关系“多对一”。这样,两者都将得到适当的“绑定”。

src/comments/comment.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
  ManyToOne,
} from 'typeorm';

import { Entry } from '../entries/entry.entity';

@Entity()
export class Comment {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column('text') body: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @ManyToOne(type => Entry, entry => entry.comments)
  entry: Entry;

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

我们传递给@OneToMany()@ManyToOne()装饰器的第二个参数用于指定我们在另一个相关实体上创建的逆关系。换句话说,在Entry中,我们将相关的Comment实体保存在名为comments的属性中。这就是为什么在Comment实体定义中,我们将entry => entry.comments作为第二个参数传递给装饰器的原因,直到在Entry中存储评论。

注意:并非所有关系需要是双向的。“一对一”关系可以是单向的或双向的。在单向“一对一”关系的情况下,关系的所有者是声明它的一方,而另一个实体不需要知道关于第一个实体的任何信息。

就是这样!现在我们的每个条目都可以有多条评论。

如何存储相关实体

如果我们谈论代码,保存属于条目的评论的最直接的方法将是保存评论,然后保存包含新评论的条目。创建一个新的Comments服务来与实体交互,然后修改Entry控制器以调用该新的Comments服务。

让我们看看。这并不像听起来那么难!

这将是我们的新服务:

src/comments/comments.service.ts

import { Component } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { Comment } from './comment.entity';

@Injectable()
export class CommentsService {
  constructor(
    @InjectRepository(Comment) private readonly comment: Repository<Comment>
  ) {}

  findAll() {
    return this.comment.find();
  }

  findOneById(id: number) {
    return this.comment.findOneById(id);
  }

  create(comment: Comment) {
    return this.comment.save(comment);
  }
}

代码看起来确实很熟悉,不是吗?这与我们已经拥有的EntriesService非常相似,因为我们为评论和条目提供了相同的功能。

这将是修改后的Entries控制器:

src/entries/entries.controller.ts

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

import { EntriesService } from './entries.service';
import { CommentsService } from '../comments/comments.service';

import { Entry } from './entry.entity';
import { Comment } from '../comments/comment.entity';

@Controller('entries')
export class EntriesController {
  constructor(
    private readonly entriesSrv: EntriesService,
    private readonly commentsSrv: CommentsService
  ) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }

  @Get(':entryId')
  findOneById(@Param('entryId') entryId) {
    return this.entriesSrv.findOneById(entryId);
  }

  @Post()
  async create(@Body() input: { entry: Entry; comments: Comment[] }) {
    const { entry, comments } = input;
    entry.comments: Comment[] = [];
    await comments.forEach(async comment => {
      await this.commentsSrv.create(comment);
      entry.comments.push(comment);
    });
    return this.entriesSrv.create(entry);
  }
}

简而言之,新的create()方法:

  • 接收一个博客条目和属于该条目的评论数组。

  • 在博客条目对象内创建一个新的空数组属性(名为comments)。

  • 遍历接收到的评论,保存每一条评论,然后逐一将它们推送到entry的新comments属性中。

  • 最后,保存了现在包含每条评论链接的entry

以更简单的方式保存相关实体

我们上次编写的代码有效,但不太方便。

幸运的是,TypeORM 为我们提供了一种更简单的方法来保存相关实体:启用“级联”。

在实体中将cascade设置为true将意味着我们将不再需要单独保存每个相关实体;相反,将关系的所有者保存到数据库将同时保存这些相关实体。这样,我们以前的代码可以简化。

首先,让我们修改我们的Entry实体(它是关系的所有者)以启用级联。

src/entries/entry.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
  OneToMany,
} from 'typeorm';

import { Comment } from '../comments/comment.entity';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @OneToMany(type => Comment, comment => comment.id, {
    cascade: true,
  })
  comments: Comment[];

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

这真的很简单:我们只需为@OneToMany()装饰器的第三个参数添加一个{cascade: true}对象。

现在,我们将重构Entries控制器上的create()方法。

src/entries/entries.controller.ts

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

import { EntriesService } from './entries.service';

import { Entry } from './entry.entity';
import { Comment } from '../comments/comment.entity';

@Controller('entries')
export class EntriesController {
  constructor(private readonly entriesSrv: EntriesService) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }

  @Get(':entryId')
  findAll(@Param('entryId') entryId) {
    return this.entriesSrv.findOneById(entryId);
  }

  @Post()
  async create(@Body() input: { entry: Entry; comments: Comment[] }) {
    const { entry, comments } = input;
    entry.comments = comments;
    return this.entriesSrv.create(entry);
  }
}

请将新控制器与我们以前的实现进行比较;我们已经摆脱了对Comments服务的依赖,以及对create()方法的迭代器。这使我们的代码更短,更清晰,这总是好的,因为它减少了引入错误的风险。

在这一部分,我们发现了如何保存彼此相关的实体,同时保存它们的关系。这对于我们相关实体的成功至关重要。干得好!

批量检索相关实体

现在我们知道如何保存一个实体并包含它的关系,我们将看看如何从数据库中读取一个实体以及它们的所有相关实体。

在这种情况下的想法是,当我们从数据库请求博客条目(只有一个)时,我们还会得到属于它的评论。

当然,由于你对博客一般情况比较熟悉(它们已经存在一段时间了,对吧?),你会意识到并不是所有的博客都会同时加载博客文章和评论;很多博客只有在你滚动到页面底部时才加载评论。

为了演示功能,我们将假设我们的博客平台将同时检索博客文章和评论。

我们需要修改Entries服务来实现这一点。再次强调,这将非常容易!

src/entries/entries.service.ts

import { Component } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { Entry } from './entry.entity';

@Injectable()
export class EntriesService {
  constructor(
    @InjectRepository(Entry) private readonly entry: Repository<Entry>
  ) {}

  findAll() {
    return this.entry.find();
  }

  findOneById(id: number) {
    return this.entry.findOneById(id, { relations: ['comments'] });
  }

  create(newEntry: Entry) {
    this.entry.save(newEntry);
  }
}

我们只在Entry存储库的findOneById()方法的第二个参数中添加了{ relations: ['comments'] }。选项对象的relations属性是一个数组,因此我们可以检索出我们需要的任意多个关系。它也可以与任何find()相关方法一起使用(即find()findByIds()findOne()等等)。

懒惰关系

在使用 TypeORM 时,常规关系(就像我们迄今为止写的那样)是急切关系。这意味着当我们从数据库中读取实体时,find*()方法将返回相关的实体,而无需我们编写连接或手动读取它们。

我们还可以配置我们的实体将关系视为懒惰,这样相关的实体在我们说之前不会从数据库中检索出来。

这是通过将保存相关实体的字段类型声明为Promise而不是直接类型来实现的。让我们看看代码上的区别:

// This relationship will be treated as eager
@OneToMany(type => Comment, comment => comment.id)
comments: Comment[];

// This relationship will be treated as lazy
@OneToMany(type => Comment, comment => comment.id)
comments: Promise<Comment[]>;

当然,使用懒惰关系意味着我们需要改变保存实体到数据库的方式。下一个代码块演示了如何保存懒惰关系。请注意create()方法。

src/entries/entries.controller.ts

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

import { EntriesService } from './entries.service';
import { CommentsService } from '../comments/comments.service';

import { Entry } from './entry.entity';
import { Comment } from '../comments/comment.entity';

@Controller('entries')
export class EntriesController {
  constructor(
    private readonly entriesSrv: EntriesService,
    private readonly commentsSrv: CommentsService
  ) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }

  @Get(':entryId')
  findAll(@Param('entryId') entryId) {
    return this.entriesSrv.findOneById(entryId);
  }

  @Post()
  async create(@Body() input: { entry: Entry; comments: Comment[] }) {
    const { entry, comments } = input;
    const resolvedComments = [];
    await comments.forEach(async comment => {
      await this.commentsSrv.create(comment);
      resolvedComments.push(comment);
    });
    entry.comments = Promise.resolve(resolvedComments);
    return this.entriesSrv.create(entry);
  }
}

通过以下方式使create()方法变为“懒惰”:

  1. 初始化一个新的resolvedComments空数组。

  2. 遍历请求中收到的所有评论,保存每一条评论,然后将其添加到resolvedComments数组中。

  3. 当所有评论都被保存时,我们将一个 promise 分配给entrycomments属性,然后立即用第 2 步中构建的评论数组解决它。

  4. 将带有相关评论的entry保存为已解决的 promise。

在保存之前将一个立即解决的 promise 分配为实体的值的概念并不容易理解。但是,由于 JavaScript 的异步性质,我们仍然需要诉诸于这一点。

话虽如此,请注意 TypeORM 对懒惰关系的支持仍处于实验阶段,因此请谨慎使用。

其他类型的关系

到目前为止,我们已经探讨了“一对多”的关系。显然,TypeORM 也支持“一对一”和“多对多”的关系。

一对一

以防你不熟悉这种关系,其背后的想法是一个实体的一个实例,只属于另一个实体的一个实例,而且只属于一个。

举个更具体的例子,假设我们要创建一个新的EntryMetadata实体来存储我们想要跟踪的新事物,比如,假设博客文章从读者那里得到的喜欢数量和每篇博客文章的短链接。

让我们从创建一个名为EntryMetadata的新实体开始。我们将把文件放在/entry文件夹中,与entry.entity.ts文件相邻。

src/entries/entry_metadata.entity.ts

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class EntryMetadata {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() likes: number;

  @Column() shortlink: string;
}

我们刚刚创建的实体非常简单:它只有常规的uuid属性,以及用于存储条目的likesshortlink的两个其他属性。

现在让我们告诉 TypeORM 在每个Entry实例中包含一个EntryMetadata实体的实例。

src/entries/entry.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
  OneToMany,
  OneToOne,
  JoinColumn,
} from 'typeorm';

import { EntryMetadata } from './entry-metadata.entity';
import { Comment } from '../comments/comment.entity';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @OneToOne(type => EntryMetadata)
  @JoinColumn()
  metadata: EntryMetadata;

  @OneToMany(type => Comment, comment => comment.id, {
    cascade: true,
  })
  comments: Comment[];

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

您可能已经注意到了@JoinColumn()装饰器。在“一对一”关系中使用这个装饰器是 TypeORM 所要求的。

双向一对一关系

此时,EntryEntryMetadata之间的关系是单向的。在这种情况下,这可能已经足够了。

然而,假设我们想直接访问EntryMetadata实例,然后获取它所属的Entry实例的可能性。好吧,现在我们还不能做到;直到我们使关系双向为止。

因此,仅出于演示目的,我们将在EntryMetadata实例中包含到Entry实例的反向关系,以便你知道它是如何工作的。

src/entries/entry_metadata.entity.ts

import { Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm';

import { Entry } from './entry.entity';

@Entity()
export class EntryMetadata {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() likes: number;

  @Column() shortlink: string;

  @OneToOne(type => Entry, entry => entry.metadata)
  entry: Entry;
}

确保不要在第二个条目中包含@JoinColumn()装饰器。该装饰器应该只用在拥有者实体中;在我们的情况下,就是Entry中。

我们需要做的第二个调整是指向原始@OneToOne()装饰器中相关实体的位置。记住,我们刚刚看到这需要通过向装饰器传递第二个参数来完成,就像这样:

src/entries/entry.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  VersionColumn,
  OneToMany,
  OneToOne,
  JoinColumn,
} from 'typeorm';

import { EntryMetadata } from './entry-metadata.entity';
import { Comment } from '../comments/comment.entity';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @OneToOne(type => EntryMetadata, entryMetadata => entryMetadata.entry)
  @JoinColumn()
  metadata: EntryMetadata;

  @OneToMany(type => Comment, comment => comment.id, {
    cascade: true,
  })
  comments: Comment[];

  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

就是这样!现在我们有了一个美丽的、工作正常的EntryEntryMetadata实体之间的双向一对一关系。

顺便说一句,如果你想知道我们如何保存然后检索这两个相关的实体,我有好消息告诉你:它的工作方式与我们在本章前面看到的一对多关系相同。因此,要么像在本章前面介绍的那样手动操作,要么(我个人的最爱)使用“级联”来保存它们,并使用find*()来检索它们!

多对多

我们可以为我们的实体建立的最后一种关系类型被称为“多对多”。这意味着拥有实体的多个实例可以包含拥有实体的多个实例。

一个很好的例子可能是我们想要为我们的博客条目添加“标签”。一个条目可能有几个标签,一个标签可以用在几个博客条目中,对吧。这使得关系属于“多对多”类型。

我们将节省一些代码,因为这些关系的声明方式与“一对一”关系完全相同,只需将@OneToOne()装饰器更改为@ManyToMany()

高级 TypeORM

让我们来看看安全。

首先是安全

如果你在本书的 Sequelize 章节中阅读过,你可能对生命周期钩子的概念很熟悉。在那一章中,我们使用beforeCreate钩子在将用户密码保存到数据库之前对其进行加密。

如果你想知道 TypeORM 中是否也存在这样的东西,答案是肯定的!尽管 TypeORM 文档将它们称为“监听器”。

因此,为了演示其功能,让我们编写一个非常简单的User实体,其中包含用户名和密码,并且在将其保存到数据库之前,我们将确保加密密码。我们将在 TypeORM 中使用的特定监听器称为beforeInsert

@Entity
export class User {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() username: string;

  @Column() password: string;

  @BeforeInsert()
  encryptPassword() {
    this.password = crypto.createHmac('sha256', this.password).digest('hex');
  }
}

其他监听器

一般来说,监听器是在 TypeORM 中特定事件发生时触发的方法,无论是与写相关还是与读相关。我们刚刚了解了@BeforeInsert()监听器,但我们还有其他一些可以利用的监听器:

  • @AfterLoad()

  • @BeforeInsert()

  • @AfterInsert()

  • @BeforeUpdate()

  • @AfterUpdate()

  • @BeforeRemove()

  • @AfterRemove()

组合和扩展实体

TypeORM 提供了两种不同的方式来减少实体之间的代码重复。其中一种遵循组合模式,而另一种遵循继承模式。

尽管很多作者都支持组合优于继承,但我们将在这里介绍这两种可能性,并让读者决定哪种更适合他/她自己的特定需求。

嵌入式实体

在 TypeORM 中组合实体的方式是使用一种称为嵌入式实体的工件。

嵌入式实体基本上是具有一些声明的表列(属性)的实体,可以包含在其他更大的实体中。

让我们以一个例子开始:在审查我们之前为EntryComment实体编写的代码之后,我们很容易看到(除其他外)有三个重复的属性:created_atmodified_atrevision

创建一个“可嵌入”实体来保存这三个属性然后将它们嵌入到我们的原始实体中会是一个很好的主意。让我们看看如何做。

我们首先将创建一个Versioning实体(名称不太好,我知道,但应该能让您看到这个想法)带有这三个重复的属性。

src/common/versioning.entity.ts

import { CreateDateColumn, UpdateDateColumn, VersionColumn } from 'typeorm';

export class Versioning {
  @CreateDateColumn() created_at: Date;

  @UpdateDateColumn() modified_at: Date;

  @VersionColumn() revision: number;
}

请注意,我们在这个实体中没有使用@Entity 装饰器。这是因为它不是一个“真正”的实体。把它想象成一个“抽象”实体,即一个我们永远不会直接实例化的实体,而是我们将用它来嵌入到其他可实例化的实体中,以便为它们提供一些可重用的功能。换句话说,从较小的部分组合实体。

因此,现在我们将把这个新的“可嵌入”实体嵌入到我们的两个原始实体中。

src/entries/entry.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  OneToMany,
  OneToOne,
  JoinColumn,
} from 'typeorm';

import { EntryMetadata } from './entry-metadata.entity';
import { Comment } from '../comments/comment.entity';
import { Versioning } from '../common/versioning.entity';

@Entity()
export class Entry {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column() title: string;

  @Column('text') body: string;

  @Column() image: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @OneToOne(type => EntryMetadata, entryMetadata => entryMetadata.entry)
  @JoinColumn()
  metadata: EntryMetadata;

  @OneToMany(type => Comment, comment => comment.id, {
    cascade: true,
  })
  comments: Comment[];

  @Column(type => Versioning)
  versioning: Versioning;
}

src/comments/comment.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

import { Versioning } from '../common/versioning.entity';

@Entity()
export class Comment {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column('text') body: string;

  @Column('simple-json') author: { first_name: string; last_name: string };

  @Column(type => Versioning)
  versioning: Versioning;
}

即使在这个非常简单的例子中,我们已经将两个原始实体从三个不同的属性减少到了一个!在Entry实体和Comment实体中,当我们调用它们的读取或写入方法时,versioning列将被Versioning嵌入实体内的属性实际替换。

实体继承

TypeORM 为在我们的实体之间重用代码提供了第二种选择,即使用实体继承。

如果您已经熟悉 TypeScript,那么当您考虑到实体只是带有一些装饰器的常规 TS 类时,实体继承就很容易理解(和实现)。

对于这个特定的例子,让我们假设我们基于 Nest.js 的博客已经在线上一段时间了,并且它已经相当成功。现在我们想要引入赞助博客条目,这样我们就可以赚一些钱并将它们投资到更多的书籍中。

问题是,赞助条目将与常规条目非常相似,但会有一些新属性:赞助商名称和赞助商网址。

在这种情况下,经过一番思考后,我们可能决定扩展我们的原始Entry实体并创建一个SponsoredEntry

src/entries/sponsored-entry.entity.ts

import { Entity, Column } from 'typeorm';

import { Entry } from './entry.entity';

@Entity()
export class SponsoredEntry extends Entry {
  @Column() sponsorName: string;

  @Column() sponsorUrl: string;
}

就是这样。我们从SponsoredEntry实体创建的任何新实例都将具有来自扩展的Entry实体的相同列,以及我们为SponsoredEntry定义的两个新列。

缓存

TypeORM 默认提供了一个缓存层。我们可以利用它,只需稍微增加一点开销。如果您正在设计一个预期会有大量流量和/或您需要尽可能获得最佳性能的 API,这一层将特别有用。

这两种情况都会因为使用更复杂的数据检索场景(例如复杂的find*()选项,大量相关实体等)而越来越受益于缓存。

在连接到数据库时,缓存需要显式激活。到目前为止,在我们的情况下,这将是我们在本章开头创建的ormconfig.json文件。

ormconfig.json

{
  "type": "mariadb",
  "host": "db",
  "port": 3306,
  "username": "nest",
  "password": "nest",
  "database": "nestbook",
  "synchronize": true,
  "entities": ["src/**/*.entity.ts"],
  "cache": true
}

在连接上激活缓存层之后,我们需要将cache选项传递给我们的find*()方法,就像下面的例子中那样:

this.entry.find({ cache: true });

上面的代码将使.find()方法在缓存值存在且未过期时返回缓存值,否则返回相应数据库表中的值。因此,即使在过期时间窗口内调用该方法三千次,实际上只会执行一次数据库查询。

TypeORM 在处理缓存时使用了一些默认值:

  1. 默认的缓存生命周期是 1,000 毫秒(即 1 秒)。如果我们需要自定义过期时间,我们只需要将所需的生命周期作为值传递给选项对象的cache属性。在上面的例子中,this.entry.find({ cache: 60000 })将设置 60 秒的缓存 TTL。

  2. TypeORM 将在您已经使用的同一数据库中为缓存创建一个专用表。该表将被命名为query-result-cache。这并不是坏事,但如果我们有一个可用的 Redis 实例,它可以得到很大的改进。在缓存中,我们需要在ormconfig.json文件中包含我们的 Redis 连接详细信息:

ormconfig.json

{
  "type": "mariadb",
  ...
  "cache": {
    "type": "redis",
    "options": {
      "host": "localhost",
      "port": 6379
    }
  }
}

这样我们可以在高负载下轻松提高 API 的性能。

构建查询

TypeORM 的存储库方法极大地隔离了我们查询的复杂性。它们提供了一个非常有用的抽象,使我们不需要关心实际的数据库查询。

然而,除了使用这些不同的.find*()方法之外,TypeORM 还提供了手动执行查询的方法。这在访问我们的数据时极大地提高了灵活性,但代价是需要我们编写更多的代码。

TypeORM 执行查询的工具是QueryBuilder。一个非常基本的例子可能涉及重构我们旧有的findOneById()方法,使其使用QueryBuilder

src/entries/entries.service.ts

import {getRepository} from "typeorm";
...

findOneById(id: number) {
  return getRepository(Entry)
    .createQueryBuilder('entry')
    .where('entry.id = :id', { id })
    .getOne();
}

...

另一个稍微复杂一些的情景是构建一个连接,以便还检索相关的实体。我们将再次回到我们刚刚修改以包括相关评论的findOneById()方法。

src/entries/entries.service.ts

import {getRepository} from "typeorm";
...

findOneById(id: number) {
  return getRepository(Entry)
    .createQueryBuilder('entry')
    .where('entry.id = :id', { id })
    .leftJoinAndSelect('entry.comments', 'comment')
    .getOne();
}

...

从现有数据库构建我们的模型

直到这一点,我们从一个“干净”的数据库开始,然后创建我们的模型,将模型转换为数据库列的任务交给了 TypeORM。

这是“理想”的情况,但是...如果我们发现自己处于相反的情况下怎么办?如果我们已经有一个填充了表和列的数据库呢?

有一个很好的开源项目可以用于这个:typeorm-model-generator。它被打包为一个命令行工具,可以使用npx运行。

注意:如果您对此不熟悉,npx是一个随npm > 5.2 一起提供的命令,它允许我们在命令行中运行 npm 模块,而无需先安装它们。要使用它,您只需要在工具的常规命令之前加上npx。例如,如果我们想要使用 Angular CLI 在命令行中创建一个新项目,我们将使用npx ng new PROJECT-NAME

当它被执行时,typeorm-model-generator 将连接到指定的数据库(它支持大致与 TypeORM 相同的数据库),并将根据我们作为命令行参数传递的设置生成实体。

由于这是一个仅适用于一些非常特定用例的有用工具,我们将在本书中略去配置细节。但是,如果您发现自己在使用这个工具,请前往其 GitHub 存储库查看。

总结

TypeORM 是一个非常有用的工具,使我们能够在处理数据库时进行大量的繁重工作,同时大大抽象了数据建模、查询和复杂的连接,从而简化了我们的代码。

由于 Nest.js 通过@nest/typeorm包提供了很好的支持,因此它也非常适合用于 Nest.js 项目。

本章涵盖的一些内容包括:

  • TypeORM 支持的数据库类型以及如何选择其中一种的一些建议。

  • 如何将 TypeORM 连接到您的数据库。

  • 什么是实体以及如何创建您的第一个实体。

  • 从您的数据库中存储和检索数据。

  • 利用 TypeORM 使处理元数据(ID、创建和修改日期等)更容易。

  • 自定义数据库中列的类型以匹配您的需求。

  • 建立不同实体之间的关系以及在从数据库读取和写入时如何处理它们。

  • 更高级的程序,如通过组合或继承重用代码;连接到生命周期事件;缓存;以及手动构建查询。

总的来说,我们真的认为你对 Nest.js 越熟悉,就越有可能开始感觉写 TypeORM 代码更舒适,因为它们在一些方面看起来很相似,比如它们广泛使用 TypeScript 装饰器。

在下一章中,我们将介绍 Sequelize,这是一个基于 Promise 的 ORM。

第六章:Sequelize

Sequelize 是一个基于承诺的 ORM,适用于 Node.js v4 及更高版本。这个 ORM 支持许多方言,比如:

  • PostgreSQL

  • MySQL

  • SQLite

  • MSSQL

这为事务提供了可靠的支持。使用 Sequelize,您可以使用sequelize-typescript,它提供了装饰器来放置在您的实体中,并管理模型的所有字段,带有类型和约束。

此外,Sequelize 来自许多钩子,为您提供了重要的优势,可以在事务的任何级别检查和操作数据。

在本章中,我们将看到如何使用postgresql配置您的数据库以及如何配置到您的数据库的连接。之后,我们将看到如何实现我们的第一个实体,这将是一个简单的User实体,然后如何为此实体创建一个提供者,以便将实体注入到UserService中。我们还将通过umzug看到迁移系统,以及如何创建我们的第一个迁移文件。

您可以查看存储库的src/modules/databasesrc/modules/user/src/shared/config/src/migrations /migrate.ts

配置 Sequelize

为了能够使用 Sequelize,我们首先必须设置 sequelize 和我们的数据库之间的连接。为此,我们将创建DatabaseModule,其中将包含 sequelize 实例的提供者。

为了设置这个连接,我们将定义一个配置文件,其中将包含连接到数据库所需的所有属性。此配置将必须实现IDatabaseConfig接口,以避免忘记一些参数。

export interface IDatabaseConfigAttributes {
    username: string;
    password: string;
    database: string;
    host: string;
    port: number;
    dialect: string;
    logging: boolean | (() => void);
    force: boolean;
    timezone: string;
}

export interface IDatabaseConfig {
    development: IDatabaseConfigAttributes;
}

此配置应该设置为以下示例,并通过环境变量或默认值设置参数。

export const databaseConfig: IDatabaseConfig = {
    development: {
        username: process.env.POSTGRES_USER ||             'postgres',
        password: process.env.POSTGRES_PASSWORD || null,
        database: process.env.POSTGRES_DB || 'postgres',
        host: process.env.DB_HOST || '127.0.0.1',
        port: Number(process.env.POSTGRES_PORT) || 5432,
        dialect: 'postgres',
        logging: false,
        force: true,
        timezone: '+02:00',
    }
};

配置完成后,您必须创建适当的提供者,其目的是使用正确的配置创建 sequelize 实例。在我们的情况下,我们只是设置了环境配置,但您可以使用相同的模式设置所有配置,只需要更改值。

这个实例是让你了解应该提供的不同模型。为了告诉 sequelize 我们需要哪个模型,我们在实例上使用addModels方法,并传递一个模型数组。当然,在接下来的部分中,我们将看到如何实现一个新模型。

export const databaseProvider = {
    provide: 'SequelizeInstance',
    useFactory: async () => {
        let config;
        switch (process.env.NODE_ENV) {
            case 'prod':
            case 'production':
            case 'dev':
            case 'development':
            default:
                config = databaseConfig.development;
        }

        const sequelize = new Sequelize(config);
        sequelize.addModels([User]);
        return sequelize;
    }
};

此提供者将返回 Sequelize 的实例。这个实例将有助于使用 Sequelize 提供的事务。此外,为了能够注入它,我们在provide参数中提供了令牌SequelizeInstance的名称,这将用于注入它。

Sequelize 还提供了一种立即同步模型和数据库的方法,使用sequelize.sync()。这种同步不应该在生产模式下使用,因为它每次都会重新创建一个新的数据库并删除所有数据。

我们现在已经设置好了我们的 Sequelize 配置,并且需要设置DatabaseModule,如下例所示:

@Global()
@Module({
    providers: [databaseProvider],
    exports: [databaseProvider],
})
export class DatabaseModule {}

我们将DatabaseModule定义为Global,以便将其添加到所有模块作为相关模块,让您可以将提供者SequelizeInstance注入到任何模块中,如下所示:

@Inject('SequelizeInstance`) private readonly sequelizeInstance

我们现在有一个完整的工作模块来访问我们数据库中的数据。

创建一个模型

设置好 sequelize 连接后,我们必须实现我们的模型。如前一节所示,我们告诉 Sequelize 我们将使用此方法sequelize.addModels([User]);来拥有User模型。

您现在看到了设置它所需的所有功能。

@Table

这个装饰器将允许您配置我们对数据的表示,以下是一些参数:

{

    timestamps:  true,
    paranoid:  true,
    underscored:  false,
    freezeTableName:  true,
    tableName:  'my_very_custom_table_name'
}

timestamp参数将告诉你想要有updatedAtdeletedAt列。paranoid参数允许你软删除数据而不是删除它以避免丢失数据。如果你传递true,Sequelize 将期望有一个deletedAt列以设置删除操作的日期。

underscored参数将自动将所有驼峰命名的列转换为下划线命名的列。

freezTableName将提供一种避免 Sequelize 将表名变为复数形式的方法。

tableName允许你设置表的名称。

在我们的案例中,我们只使用timestamp: true, tableName: 'users'来获取updatedAtcreatedAt列,并将表命名为users

@column

这个装饰器将帮助定义我们的列。你也可以不传递任何参数,这样 Sequelize 将尝试推断列类型。可以推断的类型包括stringbooleannumberDateBlob

一些参数允许我们在列上定义一些约束。比如,假设email列,我们希望这个电子邮件是一个字符串,并且不能为空,所以这个电子邮件必须是唯一的。Sequelize 可以识别电子邮件,但我们必须告诉它如何验证电子邮件,通过传递validate#isUnique方法。

看一下下面的例子。

@Column({
    type: DataType.STRING,
    allowNull: false,
    validate: {
        isEmail: true,
        isUnique: async (value: string, next: any): Promise<any> => {
            const isExist = await User.findOne({ where: { email: value }});
            if (isExist) {
                const error = new Error('The email is already used.');
                next(error);
            }
            next();
        },
    },
})

在前面的示例中,我们传递了一些选项,但我们也可以使用一些装饰器,如@AllowNull(value: boolean)@Unique甚至@Default(value: any)

为了设置一个id列,@PrimaryKey@AutoIncrement装饰器是设置约束的一种简单方法。

创建用户模型

现在我们已经看到了一些有用的装饰器,让我们创建我们的第一个模型,User。为了做到这一点,我们将创建一个类,该类必须扩展自基类Model<T>,这个类需要为自身的模板值。

export class User extends Model<User> {...}

现在我们添加了@Table()装饰器来配置我们的模型。这个装饰器接受与接口DefineOptions对应的选项,正如我们在@Table 部分中描述的,我们将传递 timestamp 为 true 和表的名称作为选项。

@Table({ timestamp: true, tableName: 'users' } as IDefineOptions)
export class User extends Model<User> {...}

现在我们需要为我们的模型定义一些列。为此,sequelize-typescript提供了@Column()装饰器。这个装饰器允许我们提供一些选项来配置我们的字段。你可以直接传递数据类型DataType.Type

@Column(DataTypes.STRING)
public email: string;

你还可以使用@Column 部分中显示的选项来验证和确保电子邮件的数据。

@Column({
    type: DataType.STRING,
    allowNull: false,
    validate: {
        isEmail: true,
        isUnique: async (value: string, next: any): Promise<any> => {
            const isExist = await User.findOne({
                where: { email: value }
            });
            if (isExist) {
                const error = new Error('The email is already used.');
                next(error);
            }
            next();
        },
    },
})
public email: string;

现在你知道如何设置列,让我们为简单的用户设置模型的其余部分。

@Table(tableOptions)
export class User extends Model<User> {
    @PrimaryKey
    @AutoIncrement @Column(DataType.BIGINT)
    public id: number;

    @Column({
        type: DataType.STRING,
        allowNull: false,
    })
    public firstName: string;

    @Column({
        type: DataType.STRING,
        allowNull: false,
    })
    public lastName: string;

    @Column({
        type: DataType.STRING,
        allowNull: false,
        validate: {
            isEmail: true,
            isUnique: async (value: string, next: any): Promise<any> => {
                const isExist = await User.findOne({
                    where: { email: value }
                });
                if (isExist) {
                    const error = new Error('The email is already used.');
                    next(error);
                }
                next();
            },
        },
    })
    public email: string;

    @Column({
        type: DataType.TEXT,
        allowNull: false,
    })
    public password: string;

    @CreatedAt
    public createdAt: Date;

    @UpdatedAt
    public updatedAt: Date;

    @DeletedAt
    public deletedAt: Date;
}

在所有添加的列中,你可以看到TEXT类型的密码,但当然,你不能将密码存储为明文,所以我们必须对其进行哈希处理以保护它。为此,使用 Sequelize 提供的生命周期钩子。

生命周期钩子

Sequelize 提供了许多生命周期钩子,允许你在创建、更新或删除数据的过程中操作和检查数据。

以下是 Sequelize 中一些有用的钩子。

  beforeBulkCreate(instances, options)
  beforeBulkDestroy(options)
  beforeBulkUpdate(options)

  beforeValidate(instance, options)
  afterValidate(instance, options)

  beforeCreate(instance, options)
  beforeDestroy(instance, options)
  beforeUpdate(instance, options)
  beforeSave(instance, options)
  beforeUpsert(values, options)

  afterCreate(instance, options)
  afterDestroy(instance, options)
  afterUpdate(instance, options)
  afterSave(instance, options)
  afterUpsert(created, options)

  afterBulkCreate(instances, options)
  afterBulkDestroy(options)
  afterBulkUpdate(options)

在这种情况下,我们需要使用@BeforeCreate装饰器来对密码进行哈希处理,并在存储到数据库之前替换原始值。

@Table(tableOptions)
export class User extends Model<User> {
    ...
    @BeforeCreate
    public static async hashPassword(user: User, options: any) {
        if (!options.transaction) throw new Error('Missing transaction.');

        user.password = crypto.createHmac('sha256', user.password).digest('hex');
    }
}

之前写的BeforeCreate允许你在将对象插入到数据库之前覆盖用户的password属性值,并确保最低限度的安全性。

将模型注入到服务中

我们的第一个User模型现在已经设置好了。当然,我们需要将其注入到服务或甚至控制器中。要在任何其他地方注入模型,我们必须首先创建适当的提供者,以便将其提供给模块。

这个提供者将定义用于注入的密钥,并将User模型作为值,我们之前已经实现了这个模型。

export const userProvider = {
    provide: 'UserRepository',
    useValue: User
};

要将其注入到服务中,我们将使用@Inject()装饰器,它可以使用前面示例中定义的字符串UserRepository

@Injectable()
export class UserService implements IUserService {
    constructor(@Inject('UserRepository') private readonly UserRepository: typeof User) { }
    ...
}

在将模型注入服务之后,您可以使用它来访问和操作数据。例如,您可以执行this.UserRepository.findAll()来在数据库中注册数据。

最后,我们必须设置模块以将userProvider作为提供者,该提供者提供对模型和UserService的访问。UserService可以导出,以便在另一个模块中使用,通过导入UserModule

@Module({
    imports: [],
    providers: [userProvider, UserService],
    exports: [UserService]
})
export class UserModule {}

使用 Sequelize 事务

您可能会注意到这行代码,if (!options.transaction) throw new Error('Missing transaction.');,在使用@BeforeCreate装饰的hashPassword方法中。如前所述,Sequelize 提供了对事务的强大支持。因此,对于每个操作或操作过程,您都可以使用事务。要使用 Sequelize 事务,请查看以下UserService的示例。

@Injectable()
export class UserService implements IUserService {
    constructor(@Inject('UserRepository') private readonly UserRepository: typeof User,
                @Inject('SequelizeInstance') private readonly sequelizeInstance) { }
    ...
}

我们在本章中提到的模型和 Sequelize 实例都已注入。

要使用事务来包装对数据库的访问,您可以执行以下操作:

public async create(user: IUser): Promise<User> {
    return await this.sequelizeInstance.transaction(async transaction => {
        return await this.UserRepository.create<User>(user, {
            returning: true,
            transaction,
        });
    });
}

我们使用sequelizeInstance创建一个新的事务,并将其传递给UserRepositorycreate方法。

迁移

使用 Sequelize,您可以同步模型和数据库。问题是,此同步将删除所有数据,以便重新创建表示模型的所有表。因此,此功能在测试中很有用,但在生产模式下则不适用。

为了操作数据库,您可以使用umzung,这是一个与框架无关的库和迁移工具,适用于 Nodejs。它与任何数据库都无关,但提供了一个 API,用于迁移或回滚迁移。

当您使用命令npm run migrate up时,它会执行ts-node migrate.ts,您可以将up/down作为参数传递。为了跟踪已应用的所有迁移,将创建一个名为SequelizeMeta的新表,并将所有已应用的迁移存储在此表中。

我们的迁移文件可以在存储库中找到,名称为migrate.ts。此外,所有迁移文件将存储在存储库示例的migrations文件夹中。

配置迁移脚本

为了配置 umzung 实例,您可以设置一些选项:

  • storage,对应于我们的sequelize字符串键

  • storageOptions,它将使用 Sequelize,并且您可以在此选项中更改用于存储已应用迁移的名称的列的默认名称modelNametableNamecolumnName属性。

还可以进行其他一些配置,以设置up方法名称和down方法名称,传递日志函数。migrations属性将允许您提供一些参数以传递给 up/down 方法,并提供要应用的迁移的路径以及适当的模式。

const umzug = new Umzug({
    storage: 'sequelize',
    storageOptions: { sequelize },

    migrations: {
        params: [
            sequelize,
            sequelize.constructor, // DataTypes
        ],
        path: './migrations',
        pattern: /\.ts$/
    },

    logging: function () {
        console.log.apply(null, arguments);
    }
});

创建迁移

要执行迁移脚本,请提供要应用的迁移。假设您想使用迁移创建users表。您必须设置updown方法。

export async function up(sequelize) {
    // language=PostgreSQL
    sequelize.query(`
        CREATE TABLE "users" (
            "id" SERIAL UNIQUE PRIMARY KEY NOT NULL,
            "firstName" VARCHAR(30) NOT NULL,
            "lastName" VARCHAR(30) NOT NULL,
            "email" VARCHAR(100) UNIQUE NOT NULL,
            "password" TEXT NOT NULL,
            "birthday" TIMESTAMP,
            "createdAt" TIMESTAMP NOT NULL,
            "updatedAt" TIMESTAMP NOT NULL,
            "deletedAt" TIMESTAMP
        );
    `);

    console.log('*Table users created!*');
}

export async function down(sequelize) {
    // language=PostgreSQL
    sequelize.query(`DROP TABLE users`);
}

在每个方法中,参数将是sequelize,这是配置文件中使用的实例。通过此实例,您可以使用查询方法来编写我们的 SQL 查询。在前面的示例中,函数up将执行查询以创建users表。down方法的目的是在回滚时删除此表。

总结

在本章中,您已经看到了如何通过实例化 Sequelize 实例来设置与数据库的连接,并使用工厂直接在另一个地方注入实例。

另外,您已经看到了 sequelize-typescript 提供的装饰器,以便设置一个新的模型。您还看到了如何在列上添加一些约束,以及如何在保存之前使用生命周期钩子来对密码进行哈希处理。当然,这些钩子可以用来验证一些数据或在执行其他操作之前检查一些信息。但您也已经看到了如何使用@BeforeCreate钩子。因此,您已经准备好使用 Sequelize 事务系统。

最后,您已经看到了如何配置 umzug 来执行迁移,并且如何创建您的第一个迁移以创建用户表。

在下一章中,您将学习如何使用 Mongoose。

第七章:Mongoose

Mongoose 是本书中将要介绍的第三个也是最后一个数据库映射工具。它是 JavaScript 世界中最知名的 MongoDB 映射工具。

关于 MongoDB 的一点说明

当 MongoDB 最初发布时,即 2009 年,它震惊了数据库世界。那时使用的绝大多数数据库都是关系型的,而 MongoDB 迅速成长为最受欢迎的非关系型数据库(也称为“NoSQL”)。

NoSQL 数据库与关系型数据库(如 MySQL、PostgreSQL 等)不同,它们以其他方式对存储的数据进行建模,而不是相互关联的表。

具体来说,MongoDB 是一种“面向文档的数据库”。它以 BSON 格式(“二进制 JSON”,一种包含特定于 MongoDB 的各种数据类型的 JSON 扩展)保存数据的“文档”。MongoDB 文档被分组在“集合”中。

传统的关系型数据库将数据分隔在表和列中,类似于电子表格。另一方面,面向文档的数据库将完整的数据对象存储在数据库的单个实例中,类似于文本文件。

虽然关系型数据库结构严格,但面向文档的数据库要灵活得多,因为开发人员可以自由使用非预定义的结构在我们的文档中,甚至可以完全改变我们的数据结构从一个文档实例到另一个文档实例。

这种灵活性和缺乏定义的结构意味着通常更容易更快地“映射”(转换)我们的对象以便将它们存储在数据库中。这为我们的项目带来了减少编码开销和更快迭代的好处。

关于 Mongoose 的一点说明

Mongoose 在技术上并不是 ORM(对象关系映射),尽管通常被称为是。相反,它是 ODM(对象文档映射),因为 MongoDB 本身是基于文档而不是关系表的。不过,ODM 和 ORM 的理念是相同的:提供一个易于使用的数据建模解决方案。

Mongoose 使用“模式”的概念。模式只是一个定义集合(一组文档)以及文档实例将具有的属性和允许的值类型的对象(即我们将称之为“它们的形状”)。

Mongoose 和 Nest.js

就像我们在 TypeORM 和 Sequelize 章节中看到的一样,Nest.js 为我们提供了一个可以与 Mongoose 一起使用的模块。

入门

首先,我们需要安装 Mongoose npm 包,以及 Nest.js/Mongoose npm 包。

在控制台中运行npm install --save mongoose @nestjs/mongoose,然后立即运行npm install --save-dev @types/mongoose

设置数据库

Docker Compose 是使用 MongoDB 最简单的方法。Docker 注册表中有一个官方的 MongoDB 镜像,我们建议您使用。目前写作本文时的最新稳定版本是3.6.4

让我们创建一个 Docker Compose 文件来构建和启动我们将使用的数据库,以及我们的 Nest.js 应用,并将它们链接在一起,以便我们可以稍后从我们的代码中访问数据库。

version: '3'

volumes:
  mongo_data:

services:
  mongo:
    image: mongo:latest
    ports:
    - "27017:27017"
    volumes:
    - mongo_data:/data/db
  api:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - NODE_ENV=development
    depends_on:
      - mongo
    links:
      - mongo
    environment:
      PORT: 3000
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    command: >
      npm run start:dev

我们指向 MongoDB 镜像的latest标签,这是一个解析为最新稳定版本的别名。如果您感到冒险,可以随意将标签更改为unstable...不过要注意可能会出现问题!

启动容器

现在您的 Docker Compose 文件已经准备好了,启动容器并开始工作吧!

在控制台中运行docker-compose up来执行。

连接到数据库

我们的本地 MongoDB 实例现在正在运行并准备好接受连接。

我们需要将几步前安装的 Nest.js/Mongoose 模块导入到我们的主应用模块中。

import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forRoot(),
    ...
  ],
})
export class AppModule {}

我们将MongooseModule添加到AppModule中,并且我们依赖forRoot()方法来正确注入依赖项。如果您阅读了关于 TypeORM 的章节,或者熟悉 Angular 及其官方路由模块,您可能会发现forRoot()方法很熟悉。

上面的代码有一个验证码:它不起作用,因为 Mongoose 或MongooseModule仍然无法找出如何连接到我们的 MongoDB 实例。

连接字符串

如果你查看 Mongoose 文档或在 Google 上快速搜索,你会发现连接到 MongoDB 实例的通常方法是使用'mongodb://localhost/test'字符串作为 Mongoose 的.connect()方法的参数(甚至在 Node MongoDB 原生客户端中)。

这个字符串就是所谓的“连接字符串”。连接字符串告诉任何 MongoDB 客户端如何连接到相应的 MongoDB 实例。

坏消息是,在我们的情况下,“默认”示例连接字符串将无法工作,因为我们正在运行我们的数据库实例,它在另一个容器中链接,一个 Node.js 容器,这是我们的代码运行的容器。

然而,好消息是,我们可以使用 Docker Compose 链接来连接到我们的数据库,因为 Docker Compose 在 MongoDB 容器和 Node.js 容器之间建立了虚拟网络连接。

所以,我们唯一需要做的就是将示例连接字符串更改为

'mongodb://mongo:27017/nest'

其中mongo是我们 MongoDB 容器的名称(我们在 Docker Compose 文件中指定了这一点),27017是 MongoDB 容器正在暴露的端口(27017 是 MongoDB 的默认端口),nest是我们将在其上存储我们的文档的集合(你可以自由地将其更改为你的喜好)。

forRoot()方法的正确参数

现在我们已经调整了我们的连接字符串,让我们修改我们原来的AppModule导入。

import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://mongo:27017/nest'),
    ...
  ],
})
export class AppModule {}

现在连接字符串作为参数添加到forRoot()方法中,因此 Mongoose 知道如何连接到数据库实例并且将成功启动。

对我们的数据进行建模

我们之前已经提到 Mongoose 使用“模式”的概念。

Mongoose 模式扮演着与 TypeORM 实体类似的角色。然而,与后者不同,前者不是类,而是从 Mongoose 定义(和导出)的Schema原型继承的普通对象。

无论如何,当你准备使用它们时,模式需要被实例化为“模型”。我们喜欢把模式看作对象的“蓝图”,把“模型”看作对象工厂。

我们的第一个模式

说到这一点,让我们创建我们的第一个实体,我们将其命名为Entry。我们将使用这个实体来存储我们博客的条目(帖子)。我们将在src/entries/entry.entity.ts创建一个新文件;这样 TypeORM 将能够找到这个实体文件,因为在我们的配置中我们指定实体文件将遵循src/**/*.entity.ts文件命名约定。

让我们创建我们的第一个模式。我们将把它用作存储我们博客条目的蓝图。我们还将把模式放在其他博客条目相关文件旁边,通过“域”(即功能)对我们的文件进行分组。

注意:你可以根据自己的喜好组织模式。我们(以及官方的 Nest.js 文档)建议将它们存储在你使用每一个模式的模块附近。无论如何,只要在需要时正确导入模式文件,你应该可以使用任何其他结构方法。

src/entries/entry.schema.ts

import { Schema } from 'mongoose';

export const EntrySchema = new mongoose.Schema({
  _id: Schema.Types.ObjectId,
  title: String,
  body: String,
  image: String,
  created_at: Date,
});

我们刚刚编写的模式是:

  1. 创建一个具有我们博客条目所需属性的对象。

  2. 实例化一个新的mongoose.Schema类型对象。

  3. 将我们的对象传递给mongoose.Schema类型对象的构造函数。

  4. 导出实例化的mongoose.Schema,以便可以在其他地方使用。

注意:在一个名为_id的属性中存储我们对象的 ID,以下划线开头,这是在使用 Mongoose 时一个有用的约定;它将使得后来能够依赖于 Mongoose 的.findById()模型方法。

将模式包含到模块中

下一步是“通知”Nest.jsMongooseModule,你打算使用我们创建的新模式。为此,我们需要创建一个“Entry”模块(如果我们还没有的话),如下所示:

src/entries/entries.module.ts

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { EntrySchema } from './entry.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: 'Entry', schema: EntrySchema }]),
  ],
})
export class EntriesModule {}

与我们在 TypeORM 章节中所做的非常相似,现在我们需要使用MongooseModuleforFeature()方法来定义它需要注册的模式,以便在模块范围内使用模型。

再次强调,这种方法受到 Angular 模块的影响,比如路由器,所以这可能对你来说很熟悉!

如果不是,请注意,这种处理依赖关系的方式极大地增加了应用程序中功能模块之间的解耦,使我们能够通过将模块添加或删除到主AppModule的导入中轻松地包含、删除和重用功能和功能。

将新模块包含到主模块中

另外,在谈到AppModule时,不要忘记将新的EntriesModule导入到根AppModule中,这样我们就可以成功地使用我们为博客编写的新功能。现在让我们来做吧!

import { MongooseModule } from '@nestjs/mongoose';

import { EntriesModule } from './entries/entries.module';

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://mongo:27017/nest'),
    EntriesModule,
    ...
  ],
})
export class AppModule {}

使用模式

如前所述,我们将使用我们刚刚定义的模式来实例化一个新的数据模型,我们将能够在我们的代码中使用它。 Mongoose 模型是将对象映射到数据库文档的重要工具,并且还抽象了操作数据的常见方法,比如.find().save()

如果你来自 TypeORM 章节,Mongoose 中的模型与 TypeORM 中的存储库非常相似。

在必须将请求连接到数据模型时,Nest.js 中的典型方法是构建专用服务,这些服务作为与每个模型的“触点”,以及控制器。这将服务与到达 API 的请求联系起来。我们将在以下步骤中遵循数据模型->服务->控制器的方法。

接口

在创建服务和控制器之前,我们需要为我们的博客条目编写一个小接口。这是因为,如前所述,Mongoose 模式不是 TypeScript 类,因此为了正确地对对象进行类型定义以便以后使用,我们需要首先为其定义一个类型。

src/entries/entry.interface.ts

import { Document } from 'mongoose';

export interface Entry extends Document {
  readonly _id: string;
  readonly title: string;
  readonly body: string;
  readonly image: string;
  readonly created_at: Date;
}

记住要保持接口与模式同步,这样你就不会在以后的对象形状问题上遇到问题。

服务

让我们为我们的博客条目创建一个服务,与Entry模型交互。

src/entries/entries.service.ts

import { Component } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';

import { EntrySchema } from './entry.schema';
import { Entry } from './entry.interface';

@Injectable()
export class EntriesService {
  constructor(
    @InjectModel(EntrySchema) private readonly entryModel: Model<Entry>
  ) {}

  // this method retrieves all entries
  findAll() {
    return this.entryModel.find().exec();
  }

  // this method retrieves only one entry, by entry ID
  findById(id: string) {
    return this.entryModel.findById(id).exec();
  }

  // this method saves an entry in the database
  create(entry) {
    entry._id = new Types.ObjectId();
    const createdEntry = new this.entryModel(entry);
    return createdEntry.save();
  }
}

在上面的代码中,最重要的部分发生在构造函数内部:我们使用@InjectModel()装饰器来实例化我们的模型,通过将期望的模式(在本例中为EntrySchema)作为装饰器参数传递。

然后,在同一行代码中,我们将模型作为服务中的依赖项注入,将其命名为entryModel并为其分配一个Model类型;从这一点开始,我们可以利用 Mongoose 模型为文档进行抽象、简化的操作提供的所有好处。

另一方面,值得一提的是,在create()方法中,我们通过使用_id属性(正如我们之前在模式中定义的)向接收到的条目对象添加一个 ID,并使用 Mongoose 内置的Types.ObjectId()方法生成一个值。

控制器

我们需要覆盖模型->服务->控制器链中的最后一步。控制器将使得可以向 Nest.js 应用程序发出 API 请求,并且可以从数据库中写入或读取数据。

这就是我们的控制器应该看起来的样子:

src/entries/entries.controller.ts

import { Controller, Get, Post, Body, Param } from '@nestjs/common';

import { EntriesService } from './entry.service';

@Controller('entries')
export class EntriesController {
  constructor(private readonly entriesSrv: EntriesService) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }

  @Get(':entryId')
  findById(@Param('entryId') entryId) {
    return this.entriesSrv.findById(entryId);
  }

  @Post()
  create(@Body() entry) {
    return this.entriesSrv.create(entry);
  }
}

像往常一样,我们正在使用 Nest.js 依赖注入,使EntryService在我们的EntryController中可用。然后,我们将我们期望监听的三个基本请求(GET所有条目,GET按 ID 获取一个条目和POST一个新条目)路由到我们服务中的相应方法。

第一个请求

此时,我们的 Nest.js API 已经准备好接收请求(包括GETPOST),并根据这些请求在我们的 MongoDB 实例中操作数据。换句话说,我们已经准备好从 API 中读取并向数据库写入数据。

让我们试一试。

我们将从对/entries端点的 GET 请求开始。显然,由于我们还没有创建任何条目,所以我们应该收到一个空数组作为响应。

> GET /entries HTTP/1.1
> Host: localhost:3000
< HTTP/1.1 200 OK

[]

让我们通过向entries端点发送POST请求并在请求体中包含一个与我们之前定义的EntrySchema形状匹配的 JSON 对象来创建一个新条目。

> GET /entries HTTP/1.1
> Host: localhost:3000
| {
|   "title": "This is our first post",
|   "body": "Bla bla bla bla bla",
|   "image": "http://lorempixel.com/400",
|   "created_at": "2018-04-15T17:42:13.911Z"
| }

< HTTP/1.1 201 Created

是的!我们之前的POST请求触发了数据库中的写入。让我们再次尝试检索所有条目。

> GET /entries HTTP/1.1
> Host: localhost:3000
< HTTP/1.1 200 OK

[{
  "id": 1,
  "title": "This is our first post",
  "body": "Bla bla bla bla bla",
  "image": "http://lorempixel.com/400",
  "created_at": "2018-04-15T17:42:13.911Z"
}]

我们刚刚确认对我们的/entries端点的请求成功执行了数据库中的读写操作。这意味着我们的 Nest.js 应用现在可以使用,因为几乎任何服务器应用程序的基本功能(即存储数据并根据需要检索数据)都正常工作。

关系

虽然 MongoDB 不是关系数据库,但它允许进行“类似于连接”的操作,以一次检索两个(或更多)相关文档。

幸运的是,Mongoose 包含了一层抽象,允许我们以清晰、简洁的方式在对象之间建立关系。这是通过在模式属性中使用ref以及.populate()方法(触发所谓的“填充”过程的方法)来实现的;稍后会详细介绍。

建模关系

让我们回到我们的博客示例。记住到目前为止我们只有一个定义博客条目的模式。我们将创建一个第二个模式,它将允许我们为每个博客条目创建评论,并以一种允许我们稍后检索博客条目以及属于它的评论的方式保存到数据库中,所有这些都可以在单个数据库操作中完成。

因此,首先,我们创建一个像下面这样的CommentSchema

src/comments/comment.schema.ts

import * as mongoose from 'mongoose';

export const CommentSchema = new mongoose.Schema({
  _id: Schema.Types.ObjectId,
  body: String,
  created_at: Date,
  entry: { type: Schema.Types.ObjectId, ref: 'Entry' },
});

在这一点上,这个模式是我们之前的EntrySchema的“精简版本”。实际上,它是由预期的功能决定的,所以我们不应该太在意这个事实。

再次,我们依赖于名为_id的属性作为命名约定。

一个值得注意的新东西是entry属性。它将用于存储每个评论所属的条目的引用。ref选项告诉 Mongoose 在填充期间使用哪个模型,我们的情况下是Entry模型。我们在这里存储的所有_id都需要是 Entry 模型的文档_id

注意: 为了简洁起见,我们将忽略Comment接口;这对你来说应该足够简单。不要忘记完成它!

其次,我们需要更新我们原始的EntrySchema,以便允许我们保存属于每个条目的Comment实例的引用。看下面的示例如何做到这一点:

src/entries/entry.schema.ts

import * as mongoose from 'mongoose';

export const EntrySchema = new mongoose.Schema({
  _id: Schema.Types.ObjectId,
  title: String,
  body: String,
  image: String,
  created_at: Date,
  comments: [{ type: Schema.Types.ObjectId, ref: 'Comment' }],
});

请注意,我们刚刚添加的comments属性是对象数组,每个对象都有一个 ObjectId 以及一个引用。关键在于包含相关对象的数组,因为这个数组使我们可以称之为“一对多”关系,如果我们处于关系数据库的上下文中。

换句话说,每个条目可以有多个评论,但每个评论只能属于一个条目。

保存关系

一旦我们的关系被建模,我们需要提供一个方法将它们保存到我们的 MongoDB 实例中。

在使用 Mongoose 时,存储模型实例及其相关实例需要一定程度的手动嵌套方法。幸运的是,async/await将使任务变得更加容易。

让我们修改我们的EntryService,以保存接收到的博客条目和与之关联的评论;两者将作为不同的对象发送到POST端点。

src/entries/entries.service.ts

import { Component } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';

import { EntrySchema } from './entry.schema';
import { Entry } from './entry.interface';

import { CommentSchema } from './comment.schema';
import { Comment } from './comment.interface';

@Injectable()
export class EntriesService {
  constructor(
    @InjectModel(EntrySchema) private readonly entryModel: Model<Entry>,
    @InjectModel(CommentSchema) private readonly commentModel: Model<Comment>
  ) {}

  // this method retrieves all entries
  findAll() {
    return this.entryModel.find().exec();
  }

  // this method retrieves only one entry, by entry ID
  findById(id: string) {
    return this.entryModel.findById(id).exec();
  }

  // this method saves an entry and a related comment in the database
  async create(input) {
    const { entry, comment } = input;

    // let's first take care of the entry (the owner of the relationship)
    entry._id = new Types.ObjectId();
    const entryToSave = new this.entryModel(entry);
    await entryToSave.save();

    // now we are ready to handle the comment
    // this is how we store in the comment the reference
    // to the entry it belongs to
    comment.entry = entryToSave._id;

    comment._id = new Types.ObjectId();
    const commentToSave = new this.commentModel(comment);
    commentToSave.save();

    return { success: true };
  }
}

修改后的create()方法现在是:

  1. 为条目分配一个 ID。

  2. 将条目保存并分配给const

  3. 为评论分配一个 ID。

  4. 使用我们之前创建的条目的 ID 作为评论的entry属性的值。这是我们之前提到的引用。

  5. 保存评论。

  6. 返回成功状态消息。

通过这种方式,我们确保在评论中成功存储了对评论所属的条目的引用。顺便说一句,注意我们通过条目的 ID 来存储引用。

显然,下一步应该是提供一种从数据库中读取我们现在能够保存到其中的相关项目的方法。

阅读关系

正如前面几节所介绍的,Mongoose 提供的从数据库一次检索相关文档的方法称为“population”,并且通过内置的.populate()方法调用它。

我们将看到如何通过再次更改EntryService来使用这种方法;在这一点上,我们将处理findById()方法。

src/entries/entries.service.ts

import { Component } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';

import { EntrySchema } from './entry.schema';
import { Entry } from './entry.interface';

import { CommentSchema } from './comment.schema';
import { Comment } from './comment.interface';

@Injectable()
export class EntriesService {
  constructor(
    @InjectModel(EntrySchema) private readonly entryModel: Model<Entry>,
    @InjectModel(CommentSchema) private readonly commentModel: Model<Comment>
  ) {}

  // this method retrieves all entries
  findAll() {
    return this.entryModel.find().exec();
  }

  // this method retrieves only one entry, by entry ID,
  // including its related documents with the "comments" reference
  findById(id: string) {
    return this.entryModel
      .findById(id)
      .populate('comments')
      .exec();
  }

  // this method saves an entry and a related comment in the database
  async create(input) {
    ...
  }
}

我们刚刚包含的.populate('comments')方法将把comments属性值从 ID 数组转换为与这些 ID 对应的实际文档数组。换句话说,它们的 ID 值被替换为通过执行单独查询从数据库返回的 Mongoose 文档。

摘要

NoSQL 数据库是“传统”关系数据库的一个强大替代品。MongoDB 可以说是当今使用的 NoSQL 数据库中最知名的,它使用 JSON 变体编码的文档。使用诸如 MongoDB 之类的基于文档的数据库允许开发人员使用更灵活、松散结构的数据模型,并可以提高在快速移动的项目中的迭代时间。

著名的 Mongoose 库是一个适配器,用于在 Node.js 中使用 MongoDB,并在查询和保存操作时抽象了相当多的复杂性。

在本章中,我们涵盖了与 Mongoose 和 Nest.js 一起工作的许多方面,比如:

  • 如何使用 Docker Compose 启动本地 MongoDB 实例。

  • 如何在我们的根模块中导入@nestjs/mongoose 模块并连接到我们的 MongoDb 实例。

  • 什么是模式,以及如何为建模我们的数据创建一个模式。

  • 建立一个管道,使我们能够以对 Nest.js 端点发出的请求的反应来写入和读取我们的 MongoDB 数据库。

  • 如何在不同类型的 MongoDB 文档之间建立关系,以及如何以有效的方式存储和检索这些关系。

在下一章中,我们将介绍 Web 套接字。

第八章:Web 套接字

正如您所见,Nest.js 通过@nestjs/websockets包提供了一种在应用程序中使用 Web 套接字的方法。此外,在框架内使用Adapter允许您实现所需的套接字库。默认情况下,Nest.js 自带适配器,允许您使用socket.io,这是一个众所周知的 Web 套接字库。

您可以创建一个完整的 Web 套接字应用程序,还可以在您的 Rest API 中添加一些 Web 套接字功能。在本章中,我们将看到如何使用 Nest.js 提供的装饰器实现在 Rest API 上使用 Web 套接字,以及如何使用特定中间件验证经过身份验证的用户。

Web 套接字的优势在于能够根据您的需求在应用程序中具有一些实时功能。对于本章,您可以查看存储库中的/src/gateways文件,还有/src/shared/adapters/src/middlewares

想象一下以下CommentGatewayModule,它看起来像这样:

@Module({
    imports: [UserModule, CommentModule],
    providers: [CommentGateway]
})
export class CommentGatewayModule { }

导入UserModule以便访问UserService,这将在以后很有用,以及CommentModule。当然,我们将创建CommentGateway,它将用作可注入服务。

WebSocketGateway

要使用 Nest.js Web 套接字实现您的第一个模块,您必须使用@WebSocketGateway装饰器。此装饰器可以接受一个对象作为参数,以提供配置如何使用适配器的方法。

参数的实现遵守GatewayMetadata接口,允许您提供:

  • port,适配器必须使用的端口

  • namespace,属于处理程序

  • 在访问处理程序之前必须应用的middlewares

所有参数都是可选的。

要使用它,您必须创建您的第一个网关类,因此想象一个UserGateway

@WebSocketGateway({
    middlewares: [AuthenticationGatewayMiddleware]
})  
export class UserGateway { /*....*/ }

默认情况下,没有任何参数,套接字将使用与您的 express 服务器相同的端口(通常为3000)。正如您所见,在前面的示例中,我们使用了@WebSocketGateway,它使用默认端口3000,没有命名空间,并且有一个稍后将看到的中间件。

网关

在先前看到的装饰器中使用的类中的网关包含您需要提供事件结果的所有处理程序。

Nest.js 带有一个装饰器,允许您访问服务器实例@WebSocketServer。您必须在类的属性上使用它。

export class CommentGateway {  
    @WebSocketServer() server; 

    /* ... */
}

此外,在整个网关中,您可以访问可注入服务。因此,为了访问评论数据,注入由CommentModule导出的CommentService,该服务已被注入到此模块中。

export class CommentGateway {
    /* ... */

    constructor(private readonly commentService: CommentService) { }

    /* ... */
}

评论服务允许您为下一个处理程序返回适当的结果。

export class CommentGateway {
    /* ... */

    @SubscribeMessage('indexComment')
    async index(client, data): Promise<WsResponse<any>> {
        if (!data.entryId) throw new WsException('Missing entry id.');

        const comments = await this.commentService.findAll({
            where: {entryId: data.entryId}
        });

        return { event: 'indexComment', data: comments };
    }

    @SubscribeMessage('showComment')
    async show(client, data): Promise<WsResponse<any>> {
        if (!data.entryId) throw new WsException('Missing entry id.');
        if (!data.commentId) throw new WsException('Missing comment id.');

        const comment = await this.commentService.findOne({
            where: {
                id: data.commentId,
                entryId: data.entryId
            }
        });

        return { event: 'showComment', data: comment };
    }
}

现在我们有两个处理程序,indexCommentshowComment。要使用indexComment处理程序,我们期望有一个entryId以提供适当的评论,而对于showComment,我们期望有一个entryId,当然还有一个commentId

正如您所见,要创建事件处理程序,请使用框架提供的@SubscribeMessage装饰器。此装饰器将使用传递的字符串作为参数创建socket.on(event),其中事件对应于事件。

认证

我们已经设置了我们的CommentModule,现在我们想使用令牌对用户进行身份验证(请查看认证章节)。在此示例中,我们使用一个共享服务器用于 REST API 和 Web 套接字事件处理程序。因此,我们将共享身份验证令牌,以查看如何验证用户登录应用程序后收到的令牌。

重要的是要保护 Web 套接字,以避免在未登录应用程序的情况下访问数据。

如前一部分所示,我们使用了名为AuthenticationGatewayMiddleware的中间件。此中间件的目的是从 Web 套接字query中获取令牌,该令牌带有auth_token属性。

如果未提供令牌,中间件将返回WsException,否则我们将使用jsonwebtoken库(请查看身份验证章节)来验证令牌。

让我们设置中间件:

@Injectable()
export class AuthenticationGatewayMiddleware implements GatewayMiddleware {
    constructor(private readonly userService: UserService) { }
    resolve() {
        return (socket, next) => {
            if (!socket.handshake.query.auth_token) {
                throw new WsException('Missing token.');
            }

            return jwt.verify(socket.handshake.query.auth_token, 'secret', async (err, payload) => {
                if (err) throw new WsException(err);

                const user = await this.userService.findOne({ where: { email: payload.email }});
                socket.handshake.user = user;
                return next();
            });
        }
    }
}

用于 Web 套接字的中间件与 REST API 几乎相同。现在实现GatewayMiddleware接口与resolve函数几乎相同。不同之处在于,您必须返回一个函数,该函数以socketnext函数作为其参数。套接字包含客户端发送的queryhandshake和所有提供的参数,我们的情况下是auth_token

与经典的身份验证中间件类似(请查看身份验证章节),套接字将尝试使用给定的有效负载查找用户,其中包含电子邮件,然后在握手中注册用户,以便在网关处理程序中访问。这是一种灵活的方式,可以在不再在数据库中查找的情况下已经拥有用户。

适配器

正如本章开头所提到的,Nest.js 自带了自己的适配器,使用socket.io。但是框架需要灵活,可以与任何第三方库一起使用。为了提供实现另一个库的方法,您可以创建自己的适配器。

适配器必须实现WebSocketAdapter接口,以实现以下方法。例如,我们将在新的适配器中使用ws作为套接字库。为了使用它,我们将不得不将app注入到构造函数中,如下所示:

export class WsAdapter implements WebSocketAdapter {
    constructor(private app: INestApplication) { }

    /* ... */
}

通过这样做,我们可以获取httpServer以便与ws一起使用。之后,我们必须实现create方法以创建套接字服务器。

export class WsAdapter implements WebSocketAdapter {
    /* ... */

    create(port: number) {
        return new WebSocket.Server({
            server: this.app.getHttpServer(),
            verifyClient: ({ origin, secure, req }, next) => { 
                return (new WsAuthenticationGatewayMiddleware(this.app.select(UserModule).
                get(UserService))).resolve()(req, next);
            }
        });
    }   

    /* ... */
}

如您所见,我们实现了verifyClient属性,该属性接受一个带有{ origin, secure, req }next值的方法。我们将使用req,即来自客户端的IncomingMessagenext方法,以便继续该过程。我们使用WsAuthenticationGatewayMiddleware来验证客户端的令牌,并注入适当的依赖项,选择正确的模块和正确的服务。

在这种情况下,中间件处理身份验证:

@Injectable()
export class WsAuthenticationGatewayMiddleware implements GatewayMiddleware {
    constructor(private userService: UserService) { }
    resolve() {
        return (req, next) => {
            const matches = req.url.match(/token=([^&].*)/);
            req['token'] = matches && matches[1];

            if (!req.token) {
                throw new WsException('Missing token.');
            }

            return jwt.verify(req.token, 'secret', async (err, payload) => {
                if (err) throw new WsException(err);

                const user = await this.userService.findOne({ where: { email: payload.email }});
                req.user = user;
                return next(true);
            });
        }
    }
}

在这个中间件中,我们必须手动解析 URL 以获取令牌,并使用jsonwebtoken进行验证。之后,我们必须实现bindClientConnect方法,将连接事件绑定到 Nest.js 将使用的回调方法。这是一个简单的方法,它接受服务器的参数和回调方法。

export class WsAdapter implements WebSocketAdapter {
    /* ... */

    bindClientConnect(server, callback: (...args: any[]) => void) {
        server.on('connection', callback);
    }

    /* ... */
}

要完成我们的新自定义适配器,实现bindMessageHandlers以将事件和数据重定向到网关的适当处理程序。该方法将使用bindMessageHandler来执行处理程序并将结果返回给bindMessageHandlers方法,后者将结果返回给客户端。

export class WsAdapter implements WebSocketAdapter {
    /* ... */

        bindMessageHandlers(client: WebSocket, handlers: MessageMappingProperties[], process: (data) => Observable<any>) {
            Observable.fromEvent(client, 'message')
                .switchMap((buffer) => this.bindMessageHandler(buffer, handlers, process))
                .filter((result) => !!result)
                .subscribe((response) => client.send(JSON.stringify(response)));
        }

        bindMessageHandler(buffer, handlers: MessageMappingProperties[], process: (data) => Observable<any>): Observable<any> {
            const data = JSON.parse(buffer.data);
            const messageHandler = handlers.find((handler) => handler.message === data.type);
            if (!messageHandler) {
                return Observable.empty();
            }
            const { callback } = messageHandler;
            return process(callback(data));
        }

    /* ... */
}

现在,我们已经创建了我们的第一个自定义适配器。为了使用它,我们必须在main.ts文件中调用app: INestApplication提供的useWebSocketAdapter,而不是 Nest.js 的IoAdapter,如下所示:

app.useWebSocketAdapter(new WsAdapter(app));

我们将适配器传递给app实例,以便像前面的示例中所示使用它。

客户端

在上一节中,我们介绍了如何在服务器端设置 Web 套接字以及如何处理来自客户端的事件。

现在我们将看到如何设置客户端,以便使用 Nest.js 的IoAdapter或我们自定义的WsAdapter。为了使用IoAdapter,我们必须获取socket.io-client库并设置我们的第一个 HTML 文件。

该文件将定义一个简单的脚本,将套接字连接到具有已登录用户令牌的服务器。这个令牌将用于确定用户是否连接良好。

检查以下代码:

<script>
    const socket = io('http://localhost:3000',  {
        query: 'auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
 eyJlbWFpbCI6InRlc3QzQHRlc3QuZnIiLCJpYXQiOjE1MjQ5NDk3NTgs
 ImV4cCI6MTUyNDk1MzM1OH0.QH_jhOWKockuV-w-vIKMgT_eLJb3dp6a
 ByDbMvEY5xc'
    });
</script>

正如您所看到的,我们在套接字连接中传递了一个名为auth_token的令牌到查询参数中。我们可以从套接字握手中获取它,然后验证套接字。

发出事件也很容易,参见以下示例:

socket.on('connect', function () {
    socket.emit('showUser', { userId: 4 });
    socket.emit('indexComment', { entryId: 2 });
    socket.emit('showComment', { entryId: 2, commentId: 1 });
});

在这个例子中,我们正在等待connect事件,以便在连接完成时得知。然后我们发送三个事件:一个是获取用户,然后是一个条目,以及条目的评论。

通过以下on事件,我们能够获取服务器作为响应我们之前发出的事件而发送的数据。

socket.on('indexComment', function (data) {
    console.log('indexComment', data);
});
socket.on('showComment', function (data) {
    console.log('showComment', data);
});
socket.on('showUser', function (data) {
    console.log('showUser', data);
});
socket.on('exception', function (data) {
    console.log('exception', data);
});

在这里,我们在控制台中显示服务器响应的所有数据,并且我们还实现了一个名为exception的事件,以便捕获服务器可能返回的所有异常。

当然,正如我们在身份验证章节中所见,用户无法访问另一个用户的数据。

在我们想要使用自定义适配器的情况下,流程是类似的。我们将使用以下方式打开到服务器的连接:

const ws = new WebSocket("ws://localhost:3000?token=eyJhbGciO
iJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QzQHRlc3QuZnIiL
CJpYXQiOjE1MjUwMDc2NjksImV4cCI6MTUyNTAxMTI2OX0.GQjWzdKXAFTAtO
kpLjId7tPliIpKy5Ru50evMzf15YE");

我们在本地主机上使用与我们的 HTTP 服务器相同的端口打开连接。我们还将令牌作为查询参数传递,以便通过verifyClient方法,这是我们在WsAuthenticationGatewayMiddleware中看到的。

接下来,我们将等待服务器的返回,以确保连接成功并可用。

ws.onopen = function() {
    console.log('open');
    ws.send(JSON.stringify({ type: 'showComment', entryId: 2, commentId: 1 }));
};

当连接可用时,使用send方法发送我们想要处理的事件类型,这里是使用showComment,并传递适当的参数,就像我们在使用 socket.io 时所做的一样。

我们将使用onmessage来获取服务器为我们之前发送的事件返回的数据。当WebSocket接收到事件时,将发送一个message事件给我们可以使用以下示例捕获的管理器。

ws.onmessage = function(ev) {
    const _data = JSON.parse(ev.data);
    console.log(_data);
};

您现在可以根据自己的喜好在客户端应用程序的其余部分中使用这些数据。

总结

在本章中,您学会了如何设置服务器端,以便使用:

  • 由 Nest.js 的IoAdapter提供的socket.io

  • 具有自定义适配器的ws

您还需要设置一个网关来处理客户端发送的事件。

您已经学会了如何设置客户端以使用socket.io-clientWebSocket客户端来连接服务器的套接字。这是在与 HTTP 服务器相同的端口上完成的,并且您学会了如何发送和捕获服务器返回的数据或在出现错误时捕获异常。

最后,您学会了如何设置身份验证中间件,以便检查提供的套接字令牌并确定用户是否经过身份验证,以便能够在IoAdapter或自定义适配器的情况下访问处理程序。

下一章将涵盖 Nest.js 的微服务。

第九章:微服务

使用 Nest.js 微服务,我们能够提取出应用程序业务逻辑的一部分,并在单独的 Nest.js 上下文中执行它。默认情况下,这个新的 Nest.js 上下文并不在新线程甚至新进程中执行。因此,“微服务”这个名称有点误导。实际上,如果您坚持使用默认的 TCP 传输,用户可能会发现请求完成的时间更长。然而,将应用程序的一些部分卸载到这个新的微服务上下文中也有好处。为了介绍基础知识,我们将坚持使用 TCP 传输,但在本章的高级架构部分中,我们将寻找一些现实世界的策略,Nest.js 微服务可以提高应用程序性能。要查看一个工作示例,请记住您可以克隆本书的附带 Git 存储库:

git clone https://github.com/backstopmedia/nest-book-example.git

服务器引导

要开始,请确保@nestjs/microservices已安装在您的项目中。该模块提供了客户端、服务器和所需的实用程序,以将 Nest.js API 应用程序转换为微服务应用程序。最后,我们将修改我们的博客应用程序的引导程序以启用微服务。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.connectMicroservice({
        transport: Transport.TCP,
        options: {
            port: 5667
        }
    });

    await app.startAllMicroservicesAsync();
    await app.listen(3001);
}

connectMicroservice方法指示 NestApplication 设置一个新的 NestMicroservice 上下文。该对象提供了设置 NestMicroservice 上下文的选项。在这里,我们保持简单,并使用 Nest.js 提供的标准 TCP 传输。调用startAllMicroservicesAsync启动 NestMicroservice 上下文。在调用 NestApplication 的listen之前,请务必这样做。

配置

传递给connectMicroservice的配置参数取决于我们使用的传输方式。传输是客户端和服务器的组合,它们协同工作以在 NestApplication 和 NestMicroservice 上下文之间传输微服务请求和响应。Nest.js 附带了许多内置传输,并提供了创建自定义传输的能力。可用的参数取决于我们使用的传输方式。现在,我们将使用 TCP 传输,但稍后会介绍其他传输方式。TCP 传输的可能选项包括:

  • host:运行 NestMicroservice 上下文的主机。默认值是假定为localhost,但如果 NestMicroservice 作为不同主机上的单独项目运行,例如不同的 Kubernetes pod,可以使用这个选项。

  • port:NestMicroservice 上下文正在侦听的端口。默认值是假定为3000,但我们将使用不同的端口来运行我们的 NestMicroservice 上下文。

  • retryAttempts:在 TCP 传输的上下文中,这是服务器在收到CLOSE事件后尝试重新建立自身的次数。

  • retryDelay:与retryAttempts一起工作,并延迟传输重试过程一定的毫秒数。

第一个微服务处理程序

对于我们的第一个微服务处理程序,让我们将 UserController 索引方法转换为微服务处理程序。为此,我们复制该方法并进行一些简单的修改。我们将不再使用Get来注释该方法,而是使用MessagePattern

@Controller()
export class UserController {

    @Get('users')
    public async index(@Res() res) {
        const users = await this.userService.findAll();
        return res.status(HttpStatus.OK).json(users);
    }

    @MessagePattern({cmd: 'users.index'})
    public async rpcIndex() {
        const users = await this.userService.findAll();
        return users;
    }
}

消息模式为 Nest.js 提供了确定要执行哪个微服务处理程序的手段。该模式可以是一个简单的字符串或一个复杂的对象。当发送新的微服务消息时,Nest.js 将搜索所有已注册的微服务处理程序,以找到与消息模式完全匹配的处理程序。

微服务方法本身可以执行与正常控制器处理程序几乎相同的业务逻辑来响应。与正常的控制器处理程序不同,微服务处理程序没有 HTTP 上下文。事实上,像@Get@Body@Req这样的装饰器在微服务控制器中没有意义,也不应该使用。为了完成消息的处理,处理程序可以返回一个简单的值、promise 或 RxJS Observable。

发送数据

之前的微服务处理程序非常牵强。更有可能的是,微服务处理程序将被实现为对数据进行一些处理并返回一些值。在正常的 HTTP 处理程序中,我们会使用@Req@Body来从 HTTP 请求的主体中提取数据。由于微服务处理程序没有 HTTP 上下文,它们将输入数据作为方法参数。

@Controller()
export class UserController {
    @Client({transport: Transport.TCP, options: { port: 5667 }})
    client: ClientProxy

    @Post('users')
    public async create(@Req() req, @Res() res) {
        this.client.send({cmd: 'users.index'}, {}).subscribe({
            next: users => {
                res.status(HttpStatus.OK).json(users);
            },
            error: error => {
                res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(error);
            }
        });
    }

    @MessagePattern({cmd: 'users.create'})
    public async rpcCreate(data: any) {
        if (!data || (data && Object.keys(data).length === 0)) throw new Error('Missing some information.');

        await this.userService.create(data);
    }
}

在这个例子中,我们使用@Client装饰器为 Nest.js 依赖注入提供了一个注入微服务客户端实例的地方。客户端装饰器接受与在引导应用程序时传递给connectMicroservice相同的配置对象。客户端是 NestApplication 上下文与 NestMicroservice 上下文进行通信的方式。使用客户端,我们修改了原始的@Post('users') API,将创建新用户的处理过程转移到了 NestMicroservice 上下文中。

微服务 TCP 流

这张图表展示了创建新用户时数据流的简化视图。客户端与微服务上下文建立 TCP 连接,并将数据库操作的处理过程转移到微服务上下文中。rpcCreate方法将返回一个成功的响应和一些数据,或者一个异常。在处理微服务消息的同时,正常的控制器处理程序将等待响应。

请注意,微服务客户端的send方法返回一个 Observable。如果你想等待来自微服务的响应,只需订阅 Observable 并使用响应对象发送结果。另外,Nest.js 将 Observables 视为一等公民,并且它们可以从处理程序中返回。Nest.js 会负责订阅 Observable。请记住,你会失去一些对响应状态码和主体的控制。但是,你可以通过异常和异常过滤器重新获得一些控制。

异常过滤器

异常过滤器提供了一种将从微服务处理程序抛出的异常转换为有意义对象的方法。例如,我们的rpcCreate方法目前抛出一个带有字符串的错误,但是当 UserService 抛出错误或者可能是 ORM 时会发生什么。这个方法可能会抛出许多不同的错误,而调用方法唯一知道发生了什么的方法是解析错误字符串。这是完全不可接受的,所以让我们来修复它。

首先创建一个新的异常类。注意我们的微服务异常扩展了 RpcException,并且在构造函数中没有传递 HTTP 状态码。这些是微服务异常和正常的 Nest.js API 异常之间唯一的区别。

export class RpcValidationException extends RpcException {
    constructor(public readonly validationErrors: ValidationError[]) {
        super('Validation failed');
    }
}

现在我们可以改变rpcCreate方法,当数据无效时抛出这个异常。

@MessagePattern({cmd: 'users.create'})
public async rpcCreate(data: any) {
    if (!data || (data && Object.keys(data).length === 0)) throw new RpcValidationException();

    await this.userService.create(data);
}

最后,创建一个异常过滤器。微服务异常过滤器与它们的正常 API 对应物不同,它们扩展了 RpcExceptionFilter 并返回一个 ErrorObservable。这个过滤器将捕获我们创建的 RpcValidationException,并抛出一个包含特定错误代码的对象。

注意throwError方法来自 RxJS 版本 6 包。如果你仍在使用 RxJS 版本 5,使用Observable.throw

@Catch(RpcValidationException)
export class RpcValidationFilter implements RpcExceptionFilter {
    public catch(exception: RpcValidationException): ErrorObservable {
        return throwError({
            error_code: 'VALIDATION_FAILED',
            error_message: exception.getError(),
            errors: exception.validationErrors
        });
    }
}

当新的异常发生时,我们所要做的就是采取行动。修改create方法以捕获从微服务客户端抛出的任何异常。在捕获中,检查error_code字段是否具有VALIDATION_FAILED的值。当它是这样时,我们可以向用户返回400的 HTTP 状态码。这将允许用户的客户端,即浏览器,以不同的方式处理错误,可能向用户显示一些消息并允许他们修复输入的数据。与将所有错误作为500的 HTTP 状态码返回给客户端相比,这提供了更好的用户体验。

@Post('users')
public async create(@Req() req, @Res() res) {
    this.client.send({cmd: 'users.create'}, body).subscribe({
        next: () => {
            res.status(HttpStatus.CREATED).send();
        },
        error: error => {
            if (error.error_code === 'VALIDATION_FAILED') {
                res.status(HttpStatus.BAD_REQUEST).send(error);
            } else {
                res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(error);
            }
        }
    });
}

管道

Nest.js 中最常用的管道是 ValidationPipe。然而,这个管道不能与微服务处理程序一起使用,因为它会抛出扩展 HttpException 的异常。在微服务中抛出的所有异常都必须扩展 RpcException。为了解决这个问题,我们可以扩展 ValidationPipe,捕获 HttpException,并抛出 RpcException。

@Injectable()
export class RpcValidationPipe extends ValidationPipe implements PipeTransform<any> {
    public async transform(value: any, metadata: ArgumentMetadata) {
        try {
            await super.transform(value, metadata);
        } catch (error) {
            if (error instanceof BadRequestException) {
                throw new RpcValidationException();
            }

            throw error;
        }

        return value;
    }
}

在使用 ValidationPipe 之前,我们必须创建一个描述我们微服务方法期望的数据格式的类。

class CreateUserRequest {
      @IsEmail()
      @IsNotEmpty()
      @IsDefined()
      @IsString()
      public email: string;

      @Length(8)
      @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)\S+$/)
      @IsDefined()
      @IsString()
      public password: string;

      @IsNotEmpty()
      @IsDefined()
      @IsString()
      public firstName: string;

      @IsNotEmpty()
      @IsDefined()
      @IsString()
      public lastName: string;
}

新的请求类使用class-validator NPM 包来验证从 Nest.js 微服务模块传递给我们微服务方法的对象。该类包含所有属性,并使用特定的装饰器描述这些属性应包含的内容。例如,email属性应该是一个电子邮件地址,不能是空的,必须被定义,并且必须是一个字符串。现在我们只需要将其连接到我们的rpcCreate方法。

@MessagePattern({cmd: 'users.create'})
@UsePipes(new RpcValidationPipe())
@UseFilters(new RpcValidationFilter())
public async rpcCreate(data: CreateUserRequest) {
    await this.userService.create(data);
}

由于微服务处理程序不使用@Body装饰器,我们需要使用@UsePipes来使用我们的新的 RpcValidationPipe。这将指示 Nest.js 根据其类类型验证输入数据。就像对 API 一样,使用验证类和 RpcValidationPipe 来将输入验证从控制器或微服务方法中卸载出来。

守卫

在微服务中,守卫的作用与普通 API 中的作用相同。它们确定特定的微服务处理程序是否应该处理请求。到目前为止,我们已经使用守卫来保护 API 处理程序免受未经授权的访问。我们应该对我们的微服务处理程序做同样的事情。尽管在我们的应用程序中,我们的微服务处理程序只从我们已经受保护的 API 处理程序中调用,但我们永远不应该假设这将始终是这种情况。

@Injectable()
export class RpcCheckLoggedInUserGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const data = context.switchToRpc().getData();
        return Number(data.userId) === data.user.id;
    }
}

新的守卫看起来与 API 的CheckLoggedInUserGuard守卫完全相同。不同之处在于传递给canActivate方法的参数。由于这个守卫是在微服务的上下文中执行的,它将获得一个微服务data对象,而不是 API 请求对象。

我们使用新的微服务守卫与我们之前使用 API 守卫的方式相同。只需在微服务处理程序上添加@UseGuards装饰器,我们的守卫现在将保护我们的微服务免受滥用。让我们为检索当前用户信息创建一个新的微服务。

@Get('users/:userId')
@UseGuards(CheckLoggedInUserGuard)
public async show(@Param('userId') userId: number, @Req() req, @Res() res) {
    this.client.send({cmd: 'users.show'}, {userId, user: req.user}).subscribe({
        next: user => {
            res.status(HttpStatus.OK).json(user);
        },
        error: error => {
            res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(error);
        }
    });
}

@MessagePattern({cmd: 'users.show'})
@UseGuards(RpcCheckLoggedInUserGuard)
public async rpcShow(data: any) {
    return await this.userService.findById(data.userId);
}

show API 处理程序现在将访问数据库的繁重工作交给了 NestMicroservice 上下文。微服务处理程序上的守卫确保,如果处理程序以某种方式在showAPI 处理程序之外被调用,它仍将保护用户数据免受未经授权的请求。但仍然存在一个问题。这个示例从数据库返回整个用户对象,包括散列密码。这是一个安全漏洞,最好通过拦截器来解决。

拦截器

微服务拦截器的功能与普通 API 拦截器没有任何不同。唯一的区别是拦截器接收到的是发送到微服务处理程序的数据对象,而不是 API 请求对象。这意味着您实际上可以编写一次拦截器,并在两种情境下使用它们。与 API 拦截器一样,微服务拦截器在微服务处理程序之前执行,并且必须返回一个 Observable。为了保护我们的rpcShow微服务端点,我们将创建一个新的拦截器,该拦截器将期望一个User数据库对象并移除password字段。

@Injectable()
export class CleanUserInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, stream$: Observable<any>): Observable<any> {
        return stream$.pipe(
            map(user => JSON.parse(JSON.stringify(user))),
            map(user => {
                return {
                    ...user,
                    password: undefined
                };
            })
        );
    }
}

@MessagePattern({cmd: 'users.show'})
@UseGuards(RpcCheckLoggedInUserGuard)
@UseInterceptors(CleanUserInterceptor)
public async rpcShow(data: any) {
    return await this.userService.findById(data.userId);
}

rpcShow微服务处理程序的响应现在将删除password字段。请注意,在拦截器中,我们必须将User数据库对象转换为 JSON 格式。这可能会因您使用的 ORM 而有所不同。使用 Sequelize,我们需要从数据库响应中获取原始数据。这是因为 ORM 的响应实际上是一个包含许多不同 ORM 方法和属性的类。通过将其转换为 JSON 格式,然后使用password: undefined的扩展运算符来删除password字段。

内置传输

TCP 传输只是 Nest.js 内置的几种传输方式之一。使用 TCP 传输,我们必须将 NestMicroservice 上下文绑定到另一个端口,占用服务器上的另一个端口,并确保 NestMicroservice 上下文在启动 NestApplication 上下文之前运行。其他内置传输可以克服这些限制并增加额外的好处。

Redis

Redis是一个简单的内存数据存储,可以用作发布-订阅消息代理。Redis 传输利用了redis NPM 包和 Redis 服务器之间传递消息的 NestApplication 和 NestMicroservice 上下文。要使用 Redis 传输,我们需要更新我们的bootstrap方法以使用正确的 NestMicroservice 配置。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.connectMicroservice({
        transport: Transport.REDIS,
        options: {
            url: process.env.REDIS_URL
        }
    });

    await app.startAllMicroservicesAsync();
    await app.listen(3001);
}

您还必须更新所有使用@Client装饰器的位置,以使用相同的设置。相反,让我们将此配置集中化,这样我们就不会重复代码,并且可以更轻松地切换传输方式。

export const microserviceConfig: RedisOptions = {
    transport: Transport.REDIS,
    options: {
        url: process.env.REDIS_URL
    }
};

Redis 传输可以采用以下选项:

  • url:Redis 服务器的 URL。默认值为redis://localhost:6379

  • retryAttempts:当连接丢失时,微服务服务器和客户端将尝试重新连接到 Redis 服务器的次数。这用于为redis NPM 包创建retry_strategy

  • retryDelay:与retryAttempts配合使用,以毫秒为单位延迟传输的重试过程。

现在我们可以更新应用程序的bootstrap以使用我们创建的microserviceConfig对象。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.connectMicroservice(microserviceConfig);

    await app.startAllMicroservicesAsync();
    await app.listen(3001);
}

最后,在 UserController 中更新@Client装饰器。

@Controller()
export class UserController {
    @Client(microserviceConfig)
    client: ClientProxy
}

启动 Redis 服务器,例如redis docker image和应用程序,所有我们的微服务事务现在将通过 Redis 服务器进行处理。下面的图表显示了在创建新用户并使用 Redis 传输时的数据流的简化视图。

微服务 Redis 流程

客户端和服务器都与 Redis 服务器建立连接。当调用client.send时,客户端会即时修改消息模式以创建发布和订阅通道。服务器消费消息并移除消息模式修改以找到正确的微服务处理程序。一旦微服务处理程序完成处理,模式再次被修改以匹配订阅通道。客户端消费这条新消息,取消订阅订阅通道,并将响应传递回调用者。

MQTT

MQTT是一种简单的消息协议,旨在在网络带宽有限时使用。MQTT 传输利用mqtt NPM 软件包和远程 MQTT 服务器在 NestApplication 和 NestMicroservice 上下文之间传递消息。数据流和微服务客户端和服务器的操作方式几乎与 Redis 传输相同。要使用 MQTT 传输,让我们更新 microserviceConfig 配置对象。

export const microserviceConfig: MqttOptions = {
    transport: Transport.MQTT,
    options: {
        url: process.env.MQTT_URL
    }
};

MQTT 传输可以采用几种选项,所有这些选项都在mqtt NPM 软件包的 Github 存储库中详细说明。最值得注意的是,传输将url选项默认设置为mqtt://localhost:1883,并且没有连接重试。如果与 MQTT 服务器的连接丢失,微服务消息将不再传递。

启动 MQTT 服务器,例如eclipse-mosquitto docker image,现在应用程序和所有微服务事务将通过 MQTT 服务器进行处理。

NATS

NATS是一个自称具有极高吞吐量的开源消息代理服务器。NATS 传输利用nats NPM 软件包和远程 NATS 服务器在 NestApplication 和 NestMicroservice 上下文之间传递消息。

export const microserviceConfig: MqttOptions = {
    transport: Transport.NATS,
    options: {
        url: process.env.NATS_URL
    }
};

NATS 传输可以采用以下选项:

  • url:NATS 服务器的 URL。默认值为nats://localhost:4222

  • name/pass:用于将 Nest.js 应用程序与 NATS 服务器进行身份验证的用户名和密码。

  • maxReconnectAttempts:当连接丢失时,服务器和客户端尝试重新连接到 NATS 服务器的次数。默认值是尝试重新连接 10 次。

  • reconnectTimeWait:与maxReconnectAttempts配合使用,以毫秒为单位延迟传输的重试过程。

  • servers:一组url字符串,所有这些字符串都是 NATS 服务器。这允许传输利用 NATS 服务器集群。

  • tls:一个布尔值,指示连接到 NATS 服务器时是否应使用 TLS。注意,默认值为 false,这意味着所有消息都以明文传递。也可以提供对象而不是布尔值,并且可以包含标准的 Node TLS 设置,如客户端证书。

启动 NATS 服务器,例如nats docker image,现在应用程序和所有微服务事务将通过 NATS 服务器进行处理。下面的图表显示了在创建新用户并使用 NATS 传输时数据流的简化视图。

微服务 NATS 流程

客户端和服务器都与 NATS 服务器建立连接。当调用client.send时,客户端会即时修改消息模式以创建发布和订阅队列。Redis 传输和 NATS 传输之间最显着的区别之一是 NATS 传输使用队列组。这意味着现在我们可以有多个 NestMicroservice 上下文,并且 NATS 服务器将在它们之间负载平衡消息。服务器消耗消息并移除消息模式修改以找到正确的微服务处理程序。一旦微服务处理程序完成处理,模式将再次修改以匹配订阅通道。客户端消耗这条新消息,取消订阅订阅通道,并将响应传递给调用者。

gRPC

gRPC是一个远程过程调用客户端和服务器,旨在与 Google 的Protocol Buffers一起使用。gRPC 和协议缓冲区是值得拥有自己的书籍的广泛主题。因此,我们将继续讨论在 Nest.js 应用程序中设置和使用 gRPC。要开始,我们需要grpc NPM 包。在我们可以为 Nest.js 应用程序编写任何代码之前,我们必须编写一个协议缓冲区文件。

syntax = "proto3";

package example.nestBook;

message User {
    string firstName = 1;
    string lastName = 2;
    string email = 3;
}

message ShowUserRequest {
    double userId = 1;
}

message ShowUserResponse {
    User user = 1;
}

service UserService {
    rpc show (ShowUserRequest) returns (ShowUserResponse);
}

上面的代码片段描述了一个名为UserService的单个 gRPC 服务。这通常将映射到您自己项目中的一个服务或控制器。该服务包含一个名为show的方法,该方法接受一个带有userId的对象,并返回一个带有user属性的对象。syntax值指示 gRPC 包我们使用的协议缓冲区语言的格式。package声明充当我们在 proto 文件中定义的所有内容的命名空间。在导入和扩展其他 proto 文件时,这是最有用的。

注意:我们保持了 proto 文件的简单,以便我们可以专注于配置 Nest.js 以使用 gRPC 微服务。

与所有其他传输方式一样,我们现在需要在我们的控制器中配置 NestMicroservice 上下文和微服务客户端。

export const microserviceConfig: GrpcOptions = {
    transport: Transport.GRPC,
    options: {
        url: '0.0.0.0:5667',
        protoPath: join(__dirname, './nest-book-example.proto'),
        package: 'example.nestBook'
    }
};

gRPC 传输可以采用以下选项:

  • url:gRPC 服务器的 URL。默认值为localhost:5000

  • 凭证:来自grpc NPM 包的ServerCedentials对象。默认情况下,使用grpc.getInsecure方法来检索默认凭证对象。这将禁用 TLS 加密。为了建立安全的通信通道,请使用grpc.createSsl并提供根 CA、私钥和公钥证书。有关凭证的更多信息可以在这里找到。

  • protoPath:proto 文件的绝对路径。

  • root:所有 proto 文件所在位置的绝对路径。这是一个可选选项,如果您不在自己的项目中导入其他 proto 文件,则很可能不需要。如果定义了此选项,它将被预置到protoPath选项之前。

  • package:用于客户端和服务器的包的名称。这应该与 proto 文件中给出的包名称匹配。

在我们真正使用 gRPC 传输之前,我们需要对我们的控制器进行一些更改。

@Controller()
export class UserController implements OnModuleInit {
    @Client(microserviceConfig)
    private client: ClientGrpc;
    private protoUserService: IProtoUserService;

    constructor(
        private readonly userService: UserService
    ) {
    }

    public onModuleInit() {
        this.protoUserService = this.client.getService<IProtoUserService>('UserService');
    }
}

请注意,我们仍然使用@Client装饰的client属性,但我们有一个新类型ClientGrpc和一个新属性protoUserService。使用 gRPC 传输时注入的客户端不再包含send方法。相反,它具有一个getService方法,我们必须使用它来检索我们在 proto 文件中定义的服务。我们使用onModuleInit生命周期钩子,以便在 Nest.js 实例化我们的模块之后立即检索 gRPC 服务,而在任何客户端尝试使用控制器 API 之前。getService方法是一个通用方法,实际上并不包含任何方法定义。相反,我们需要提供我们自己的方法。

import { Observable } from 'rxjs';

export interface IProtoUserService {
    show(data: any): Observable<any>;
}

我们可以对我们的接口更加明确,但这可以传达要点。现在我们控制器中的protoUserService属性将具有一个show方法,允许我们调用show gRPC 服务方法。

@Get('users/:userId')
@UseGuards(CheckLoggedInUserGuard)
public async show(@Param('userId') userId: number, @Req() req, @Res() res) {
    this.protoUserService.show({ userId: parseInt(userId.toString(), 10) }).subscribe({
        next: user => {
            res.status(HttpStatus.OK).json(user);
        },
        error: error => {
            res.status(HttpStatus.INTERNAL_SERVER_ERROR).json(error);
        }
    });
}

@GrpcMethod('UserService', 'show')
public async rpcShow(data: any) {
    const user =  await this.userService.findById(data.userId);
    return {
        user: {
            firstName: user.firstName,
            lastName: user.lastName,
            email: user.email
        }
    };
}

控制器的show API 方法已更新为使用protoUserService.show。这将调用rpcShow方法,但通过 gRPC 微服务传输。rpcShow方法包含不同的装饰器@GrpcMethod,而不是@MessagePattern。这对于所有 gRPC 微服务处理程序是必需的,因为微服务不再匹配模式,而是调用定义的 gRPC 服务方法。实际上,这是@GrpcMethod装饰器的两个可选参数的映射:服务名称和服务方法。

export class UserController implements OnModuleInit {
    @GrpcMethod()
    public async rpcShow(data: any) {
    }
}

在上面的例子中,我们在调用@GrpcMethod装饰器时没有定义服务名称和服务方法。Nest.js 将自动将这些值映射到方法和类名。在这个例子中,这相当于@GrpcMethod('UserController', 'rpcShow')

您可能已经注意到我们将0.0.0.0:5667作为我们 gRPC 服务器的 URL。当我们启动 Nest.js 应用程序时,它将在本地主机上创建一个 gRPC 服务器,并在端口5667上进行监听。从表面上看,这可能看起来像 TCP 传输的更复杂版本。但是,gRPC 传输的强大之处直接源自协议缓冲区的语言和平台不可知性。这意味着我们可以创建一个使用 gRPC 公开微服务的 Nest.js 应用程序,该微服务可以被任何其他语言或平台使用,只要它也使用协议缓冲区连接到我们的微服务。我们还可以创建 Nest.js 应用程序,连接到可能在其他语言(如 Go)中公开的微服务。

微服务 gRPC 流程

当使用 gRPC 传输连接到两个或更多不同 URL 的服务时,我们需要创建相等数量的 gRPC 客户端连接,每个服务器一个。上面的图表显示了如果我们将示例博客应用程序中的评论的 crud 操作转移到 Go 服务器中,处理将会是什么样子。我们使用 gRPC 客户端连接到 Nest.js 应用程序中托管的用户微服务,另外一个连接到 Go 应用程序中托管的评论微服务。

使用任何其他传输都可以获得相同的设置。但是,您需要编写额外的代码来序列化和反序列化 Nest.js 应用程序和托管微服务的 Go 应用程序之间的消息。通过使用 gRPC 传输,协议缓冲区会为您处理这些问题。

自定义传输

自定义传输允许您为 NestApplication 和 NestMicroservice 上下文之间的通信定义新的微服务客户端和服务器。您可能出于多种原因想要创建自定义传输策略:您或您的公司已经有一个没有内置 Nest.js 传输的消息代理服务,或者您需要自定义内置传输的工作方式。在我们的例子中,我们将通过实现一个新的 RabbitMQ 传输来工作。

export class RabbitMQTransportServer extends Server implements CustomTransportStrategy {
    private server: amqp.Connection = null;
    private channel: amqp.Channel = null;

    constructor(
        private readonly url: string,
        private readonly queue: string
    ) {
        super();
    }
}

Nest.js 要求所有自定义传输都实现CustomTransportStrategy接口。这迫使我们定义自己的listenclose方法。在我们的例子中,我们连接到 RabbitMQ 服务器并监听特定的频道。关闭服务器就像从 RabbitMQ 服务器断开连接一样简单。

public async listen(callback: () => void) {
    await this.init();
    callback();
}

public close() {
    this.channel && this.channel.close();
    this.server && this.server.close();
}

private async init() {
    this.server = await amqp.connect(this.url);
    this.channel = await this.server.createChannel();
    this.channel.assertQueue(`${this.queue}_sub`, { durable: false });
    this.channel.assertQueue(`${this.queue}_pub`, { durable: false });
}

通过扩展 Nest.js 的Server类,我们的自定义传输预先配备了 RxJS 处理消息的功能,这使得 Nest.js 非常出色。然而,我们的自定义传输目前并没有真正处理消息。我们需要添加逻辑,以确定消息将如何通过 RabbitMQ 发送和接收到我们的自定义传输。

public async listen(callback: () => void) {
    await this.init();
    this.channel.consume(`${this.queue}_sub`, this.handleMessage.bind(this), {
        noAck: true,
    });
    callback();
}

private async handleMessage(message: amqp.Message) {
    const { content } = message;
    const packet = JSON.parse(content.toString()) as ReadPacket & PacketId;
    const handler = this.messageHandlers[JSON.stringify(packet.pattern)];

    if (!handler) {
        return this.sendMessage({
            id: packet.id,
            err: NO_PATTERN_MESSAGE
        });
    }

    const response$ = this.transformToObservable(await handler(packet.data)) as Observable<any>;
    response$ && this.send(response$, data => this.sendMessage({
        id: packet.id,
        ...data
    }));
}

private sendMessage(packet: WritePacket & PacketId) {
    const buffer = Buffer.from(JSON.stringify(packet));
    this.channel.sendToQueue(`${this.queue}_pub`, buffer);
}

自定义传输现在将在sub频道上监听传入的消息,并在pub频道上发送响应。handleMessage方法解码消息的内容字节数组,并使用嵌入的模式对象找到正确的微服务处理程序来处理消息。例如,{cmd: 'users.create'}将由rpcCreate处理程序处理。最后,我们调用处理程序,将响应转换为 Observable,并将其传递回 Nest.js 的Server类。一旦提供了响应,它将通过我们的sendMessage方法传递,并通过pub频道传出。

由于服务器没有客户端是无用的,我们也需要创建一个客户端。RabbitMQ 客户端必须扩展 Nest.js 的ClientProxy类,并为closeconnectpublish方法提供重写。

export class RabbitMQTransportClient extends ClientProxy {
    private server: amqp.Connection;
    private channel: amqp.Channel;
    private responsesSubject: Subject<amqp.Message>;

    constructor(
        private readonly url: string,
        private readonly queue: string) {
        super();
    }

    public async close() {
        this.channel && await this.channel.close();
        this.server && await this.server.close();
    }

    public connect(): Promise<void> {
        return new Promise(async (resolve, reject) => {
            try {
                this.server = await amqp.connect(this.url);
                this.channel = await this.server.createChannel();

                const { sub, pub } = this.getQueues();
                await this.channel.assertQueue(sub, { durable: false });
                await this.channel.assertQueue(pub, { durable: false });

                this.responsesSubject = new Subject();
                this.channel.consume(pub, (message) => { this.responsesSubject.next(message); }, { noAck: true });
                resolve();
            } catch (error) {
                reject(error);
            }
        });
    }

    protected async publish(partialPacket: ReadPacket, callback: (packet: WritePacket) => void) {
    }

    private getQueues() {
        return { pub: `${this.queue}_pub`, sub: `${this.queue}_sub` };
    }
}

在我们的示例中,我们创建了一个新的连接到 RabbitMQ 服务器,并指定了pubsub通道。客户端与服务器相比,使用了相反的通道配置。客户端通过sub通道发送消息,并在pub通道上监听响应。我们还利用了 RxJS 的强大功能,通过将所有响应导入 Subject 来简化publish方法中的处理。让我们实现publish方法。

protected async publish(partialPacket: ReadPacket, callback: (packet: WritePacket) => void) {
    if (!this.server || !this.channel) {
        await this.connect();
    }

    const packet = this.assignPacketId(partialPacket);
    const { sub } = this.getQueues();

    this.responsesSubject.asObservable().pipe(
        pluck('content'),
        map(content => JSON.parse(content.toString()) as WritePacket & PacketId),
        filter(message => message.id === packet.id),
        take(1)
    ).subscribe(({err, response, isDisposed}) => {
        if (isDisposed || err) {
            callback({
                err,
                response: null,
                isDisposed: true
            });
        }

        callback({err, response});
    });

    this.channel.sendToQueue(sub, Buffer.from(JSON.stringify(packet)));
}

publish方法首先为消息分配一个唯一的 ID,并订阅响应主题以将响应发送回微服务调用者。最后,调用sendToQueue将消息作为字节数组发送到sub通道。一旦收到响应,就会触发对响应主题的订阅。订阅流的第一件事是提取响应的content并验证消息 ID 是否与最初调用publish时分配的 ID 匹配。这可以防止客户端处理不属于特定publish执行上下文的消息响应。简而言之,客户端将接收每个微服务响应,甚至可能是针对不同微服务或相同微服务的不同执行的响应。如果 ID 匹配,客户端会检查错误并使用callback将响应发送回微服务调用者。

在我们可以使用新传输之前,我们需要更新之前创建的微服务配置对象。

export const microserviceConfig = {
    url: process.env.AMQP_URL
};

export const microserviceServerConfig: (channel: string) => CustomStrategy = channel => {
    return {
        strategy: new RabbitMQTransportServer(microserviceConfig.url, channel)
    }
};

现在我们有了一个方法,可以实例化我们的自定义传输服务器。这在我们应用程序的bootstrap中用于将我们的 NestMicroservice 上下文连接到 RabbitMQ 服务器。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.connectMicroservice(microserviceServerConfig('nestjs_book'));

    await app.startAllMicroservicesAsync();
    await app.listen(3001);
}

我们自定义传输的最后一部分在我们的控制器中。由于我们使用自定义传输,我们不能再使用@ClientProxy装饰器。相反,我们必须自己实例化我们的自定义传输。你可以在构造函数中这样做:

@Controller()
export class UserController {
    client: ClientProxy;

    constructor(private readonly userService: UserService) {
        this.client = new RabbitMQTransportClient(microserviceConfig.url, 'nestjs_book');
    }
}

等等!你现在在控制器和自定义传输客户端之间创建了一个硬绑定。这会使将来迁移到不同策略变得更加困难,而且非常难以测试。相反,让我们利用 Nest.js 的依赖注入来创建我们的客户端。首先创建一个新模块来容纳和公开我们的自定义传输客户端。

const ClientProxy = {
  provide: 'ClientProxy',
  useFactory: () => new RabbitMQTransportClient(microserviceConfig.url, 'nestjs_book')
};

@Module({
    imports: [],
    controllers: [],
    components: [ClientProxy],
    exports: [ClientProxy]
})
export class RabbitMQTransportModule {}

在我们的示例中,我们给我们的组件注入了标记为'ClientProxy'的注入令牌。这只是为了保持简单,你可以随意更改它。重要的是确保用于注册组件的注入令牌也是我们在控制器构造函数中放置@Inject装饰器时使用的注入令牌。

@Controller()
export class UserController {

    constructor(
        private readonly userService: UserService,
        @Inject('ClientProxy')
        private readonly client: ClientProxy
    ) {
    }

我们的控制器现在将在运行时注入一个微服务客户端,允许 API 处理程序与微服务处理程序进行通信。更好的是,客户端现在可以在测试中被模拟重写。启动一个 RabbitMQ 服务器,比如rabbitmq docker image,并设置AMQP_URL环境变量,即amqp://guest:guest@localhost:5672,所有微服务请求将通过 RabbitMQ 服务器进行处理。

在我们的 RabbitMQ 示例中,微服务客户端和服务器的数据流以及操作方式几乎与 NATS 传输相同。就像 NATS 一样,RabbitMQ 提供了多个 NestMicroservice 上下文消费消息的能力。RabbitMQ 将在所有消费者之间进行负载均衡。

混合应用程序

当我们在本章开始实现微服务时,我们修改了启动方法来调用connectMicroservice。这是一个特殊的方法,将我们的 Nest.js 应用程序转换为混合应用程序。这意味着我们的应用程序现在包含多种上下文类型。这很简单,但这有一些影响,你应该意识到。具体来说,使用混合应用程序方法,你将无法再为 NestMicroservice 上下文附加全局过滤器、管道、守卫和拦截器。这是因为 NestMicroservice 上下文会立即启动,但在混合应用程序中不会连接。为了解决这个限制,我们可以独立地创建我们的两个上下文。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    const rpcApp = await NestFactory.createMicroservice(AppModule, microserviceServerConfig('nestjs_book'));
    rpcApp.useGlobalFilters(new RpcValidationFilter());

    await rpcApp.listenAsync();
    await app.listen(process.env.PORT || 3000);
}

现在我们正在独立创建两个应用程序上下文,我们可以利用 NestMicroservice 上下文的全局变量。为了测试这一点,我们可以更新rpcCreate处理程序以删除RpcValidationFilter。在这一点上执行应用程序仍然应该导致在请求createAPI 时不包含必需字段时返回验证错误。

@MessagePattern({cmd: 'users.create'})
public async rpcCreate(data: CreateUserRequest) {
    if (!data || (data && Object.keys(data).length === 0)) throw new RpcValidationException();
    await this.userService.create(data);
}

我们可以扩展这种启动应用程序的方法,将更多的应用程序拆分为独立的上下文。这仍然不使用多个进程或线程,但通过使用一些更高级的架构设计,我们可以获得这些好处。

高级架构设计

到目前为止,我们已经涵盖了在 Nest.js 中设置和开始编写和使用微服务所需的一切。在这一过程中,我们描述了 Nest.js 微服务的一些缺点。特别是,由于微服务不在单独的线程或进程中运行,使用 Nest.js 微服务时可能在性能方面并没有太多收益。

然而,并不是说你不能获得这些好处。Nest.js 只是没有提供开箱即用的工具。在大多数关于在生产环境中运行 NodeJS 应用程序的资料中,通常总是涵盖并推荐使用 NodeJS 的cluster模块。我们可以在我们的 Nest.js 应用程序中做同样的事情。

async function bootstrapApp() {
    const app = await NestFactory.create(AppModule);

    await app.listen(process.env.PORT || 3000);
}

async function bootstrapRpc() {
    const rpcApp = await NestFactory.createMicroservice(AppModule, microserviceServerConfig('nestjs_book'));
    rpcApp.useGlobalFilters(new RpcValidationFilter());

    await rpcApp.listenAsync();
}

if (cluster.isMaster) {
    const appWorkers = [];
    const rpcWorkers = [];

    for (let i = 0; i < os.cpus().length; i++) {
        const app = cluster.fork({
            APP_TYPE: 'NestApplication'
        });
        const rpc = cluster.fork({
            APP_TYPE: 'NestMicroservice'
        });

        appWorkers.push(app);
        rpcWorkers.push(rpc);
    }

    cluster.on('exit', function(worker, code, signal) {
        if (appWorkers.indexOf(worker) > -1) {
            const index = appWorkers.indexOf(worker);
            const app = cluster.fork({
                APP_TYPE: 'NestApplication'
            });
            appWorkers.splice(index, 1, app);
        } else if (rpcWorkers.indexOf(worker) > -1) {
            const index = rpcWorkers.indexOf(worker);
            const rpc = cluster.fork({
                APP_TYPE: 'NestMicroservice'
            });
            rpcWorkers.splice(index, 1, rpc);
        }
    });
} else {
    if (process.env.APP_TYPE === 'NestApplication') {
        bootstrapApp();
    } else if (process.env.APP_TYPE === 'NestMicroservice') {
        bootstrapRpc();
    }
}

现在,我们的 NestApplication 和 NestMicroservice 上下文不仅在自己的线程上运行,而且根据服务器上可用的 CPU 数量进行集群化。对于每个 CPU,将创建一个单独的 NestApplication 和 NestMicroservice 上下文。NestApplication 上下文线程将共享主应用程序端口。最后,由于我们使用 RabbitMQ,运行多个 NestMicroservice 上下文,我们有多个订阅者等待微服务消息。RabbitMQ 将负责在所有 NestMicroservice 实例之间负载平衡消息分发。我们使我们的应用程序更具弹性,更能够处理比本章开始时更多的用户负载。

摘要

在本章开始时,我们说“微服务”是 Nest.js 的一个误导性名称。事实上,情况可能仍然如此,但这实际上取决于许多因素。我们最初使用 TCP 传输的示例几乎无法符合所有传统定义的微服务。NestApplication 和 NestMicroservice 上下文都是从同一个进程中执行的,这意味着一个的灾难性故障可能会导致两者都崩溃。

在突出 Nest.js 开箱即用的所有传输方式之后,我们在示例博客应用程序中使用自定义的 RabbitMQ 传输重新实现了我们的微服务。我们甚至将 NestApplication 和 NestMicroservice 上下文运行在自己的线程中。这是朝着实现“微服务”名称的正确方向迈出的重要一步。

尽管我们在本书中没有涵盖具体细节,但现在显而易见的是,您不仅限于在同一个 Nest.js 项目或存储库中使用微服务。使用诸如 Redis 和 RabbitMQ 之类的传输方式,我们可以创建并使用多个 Nest.js 项目,其唯一目的是执行 NestMicroservice 上下文。所有这些项目都可以独立在 Kubernetes 集群中运行,并通过 Redis 或 RabbitMQ 传递消息进行访问。更好的是,我们可以使用内置的 gRPC 传输与其他语言编写的微服务进行通信,并部署到其他平台上。

在下一章中,我们将学习 Nest.js 中的路由和请求处理。

第十章:Nest.js 中的路由和请求处理

Nest.js 中的路由和请求处理由控制器层处理。Nest.js 将请求路由到定义在控制器类内部的处理程序方法。在控制器的方法中添加路由装饰器,如@Get(),告诉 Nest.js 为此路由路径创建一个端点,并将每个相应的请求路由到此处理程序。

在本章中,我们将使用我们的博客应用程序中的 EntryController 作为一些示例的基础,来介绍 Nest.js 中路由和请求处理的各个方面。我们将看看您可以使用的不同方法来编写请求处理程序,因此并非所有示例都与我们的博客应用程序中的代码匹配。

请求处理程序

在 EntryController 中注册的/entries路由的基本 GET 请求处理程序可能如下所示:

import { Controller, Get } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get()
    index(): Entry[] {
        const entries: Entry[] = this.entriesService.findAll();
        return entries;
    }

@Controller('entries')装饰器告诉 Nest.js 在类中注册的所有路由添加一个entries前缀。此前缀是可选的。设置此路由的等效方式如下:

import { Controller, Get } from '@nestjs/common';

@Controller()
export class EntryController {
    @Get('entries')
    index(): Entry[] {
        const entries: Entry[] = this.entriesService.findAll();
        return entries;
    }

在这里,我们不在@Controller()装饰器中指定前缀,而是在@Get('entries')装饰器中使用完整的路由路径。

在这两种情况下,Nest.js 将所有 GET 请求路由到此控制器中的index()方法。从处理程序返回的条目数组将自动序列化为 JSON 并作为响应主体发送,并且响应状态码将为 200。这是 Nest.js 中生成响应的标准方法。

Nest.js 还提供了@Put()@Delete()@Patch()@Options()@Head()装饰器,用于创建其他 HTTP 方法的处理程序。@All()装饰器告诉 Nest.js 将给定路由路径的所有 HTTP 方法路由到处理程序。

生成响应

Nest.js 提供了两种生成响应的方法。

标准方法

使用自 Nest.js 4 以来可用的标准和推荐方法,Nest.js 将自动将从处理程序方法返回的 JavaScript 对象或数组序列化为 JSON 并将其发送到响应主体中。如果返回一个字符串,Nest.js 将只发送该字符串,而不将其序列化为 JSON。

默认的响应状态码为 200,除了 POST 请求使用 201。可以通过使用@HttpCode(...)装饰器轻松地更改处理程序方法的响应代码。例如:

@HttpCode(204)
@Post()
create() {
  // This handler will return a 204 status response
}

Express 方法

在 Nest.js 中生成响应的另一种方法是直接使用响应对象。您可以要求 Nest.js 将响应对象注入到处理程序方法中,使用@Res()装饰器。Nest.js 使用express 响应对象

您可以使用响应对象重写先前看到的响应处理程序,如下所示。

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('entries')
export class EntryController {
    @Get()
    index(@Res() res: Response) {
        const entries: Entry[] = this.entriesService.findAll();
        return res.status(HttpStatus.OK).json(entries);
    }
}

直接使用 express 响应对象将条目数组序列化为 JSON 并发送 200 状态码响应。

Response对象的类型来自 express。在package.json中的devDependencies中添加@types/express包以使用这些类型。

路由参数

Nest.js 使得从路由路径接受参数变得容易。为此,您只需在路由的路径中指定路由参数,如下所示。

import { Controller, Get, Param } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get(':entryId')
    show(@Param() params) {
        const entry: Entry = this.entriesService.find(params.entryId);
        return entry;
    }
}

上述处理程序方法的路由路径为/entries/:entryId,其中entries部分来自控制器路由前缀,而由冒号表示的:entryId参数。使用@Param()装饰器注入 params 对象,其中包含参数值。

或者,您可以使用@Param()装饰器注入单个参数值,如下所示指定参数名称。

import { Controller, Get, Param } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get(':entryId')
    show(@Param('entryId') entryId) {
        const entry: Entry = this.entriesService.findOne(entryId);
        return entry;
    }
}

请求体

要访问请求的主体,请使用@Body()装饰器。

import { Body, Controller, Post } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Post()
    create(@Body() body: Entry) {
        this.entryService.create(body);
    }
}

请求对象

要访问客户端请求的详细信息,您可以要求 Nest.js 使用@Req()装饰器将请求对象注入到处理程序中。Nest.js 使用express 请求对象

例如,

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller('entries')
export class EntryController {
    @Get()
    index(@Req() req: Request): Entry[] {
        const entries: Entry[] = this.entriesService.findAll();
        return entries;
    }

Request对象的类型来自 express。在package.jsondevDependencies中添加@types/express包以使用这些类型。

异步处理程序

到目前为止,在本章中展示的所有示例都假设处理程序是同步的。在实际应用中,许多处理程序将需要是异步的。

Nest.js 提供了许多方法来编写异步请求处理程序。

异步/等待

Nest.js 支持异步请求处理程序函数。

在我们的示例应用程序中,entriesService.findAll()函数实际上返回一个Promise<Entry[]>。使用 async 和 await,这个函数可以这样写。

import { Controller, Get } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get()
    async index(): Promise<Entry[]> {
        const entries: Entry[] = await this.entryService.findAll();
        return entries;
    }

异步函数必须返回 promises,但是在现代 JavaScript 中使用 async/await 模式,处理程序函数可以看起来是同步的。接下来,我们将解决返回的 promise 并生成响应。

Promise

同样,您也可以直接从处理程序函数返回一个 promise,而不使用 async/await。

import { Controller, Get } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get()
    index(): Promise<Entry[]> {
        const entriesPromise: Promise<Entry[]> = this.entryService.findAll();
        return entriesPromise;
    }

Observables

Nest.js 请求处理程序也可以返回 RxJS Observables。

例如,如果entryService.findAll()返回的是 Observable 而不是 Promise,那么以下内容将是完全有效的。

import { Controller, Get } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Get()
    index(): Observable<Entry[]> {
        const entriesPromise: Observable<Entry[]> = this.entryService.findAll();
        return entriesPromise;
    }

没有推荐的方法来编写异步请求处理程序。使用您最熟悉的任何方法。

错误响应

Nest.js 有一个异常层,负责捕获来自请求处理程序的未处理异常,并向客户端返回适当的响应。

全局异常过滤器处理从请求处理程序抛出的所有异常。

HttpException

如果从请求处理程序抛出的异常是HttpException,全局异常过滤器将把它转换为 JSON 响应。

例如,您可以从create()处理程序函数中抛出HttpException,如果 body 无效则如此。

import { Body, Controller, HttpException, HttpStatus, Post } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Post()
    create(@Body() entry: Entry) {
        if (!entry) throw new HttpException('Bad request', HttpStatus.BAD_REQUEST);
        this.entryService.create(entry);
    }
}

如果抛出此异常,响应将如下所示:

{
    "statusCode": 400,
    "message": "Bad request"
}

您还可以通过将对象传递给HttpException构造函数来完全覆盖响应体,如下所示。

import { Body, Controller, HttpException, HttpStatus, Post } from '@nestjs/common';

@Controller('entries')
export class EntryController {
    @Post()
    create(@Body() entry: Entry) {
        if (!entry) throw new HttpException({ status: HttpStatus.BAD_REQUEST, error: 'Entry required' });
        this.entryService.create(entry);
    }
}

如果抛出此异常,响应将如下所示:

{
    "statusCode": 400,
    "error": "Entry required"
}

未识别的异常

如果异常未被识别,意味着它不是HttpException或继承自HttpException的类,则客户端将收到下面的 JSON 响应。

{
    "statusCode": 500,
    "message": "Internal server error"
}

总结

借助于我们示例博客应用程序中的 EntryController,本章涵盖了 Nest.js 中的路由和请求处理的方面。您现在应该了解各种方法,可以用来编写请求处理程序。

在下一章中,我们将详细介绍 OpenAPI 规范,这是一个 JSON 模式,可用于构建一组 restful API 的 JSON 或 YAML 定义。

第十一章:OpenAPI(Swagger)规范

OpenAPI 规范,最著名的是其前身 Swagger,是一个 JSON 模式,可用于构建一组 RESTful API 的 JSON 或 YAML 定义。OpenAPI 本身是与语言无关的,这意味着底层 API 可以使用开发人员喜欢的任何语言、任何工具或框架来构建。OpenAPI 文档的唯一关注点是描述 API 端点的输入和输出等内容。在这方面,OpenAPI 文档充当了一个文档工具,使开发人员能够轻松地以广泛已知、理解和支持的格式描述其公共 API。

然而,OpenAPI 文档不仅仅局限于文档。已开发了许多工具,这些工具能够使用 OpenAPI 文档自动生成客户端项目、服务器存根、用于直观检查 OpenAPI 文档的 API 资源管理器 UI,甚至服务器生成器。开发人员可以在swagger.io找到 Swagger Editor、Codegen 和 UI 等工具。

虽然存在一些工具可以生成 OpenAPI 文档,但许多开发人员将这些文档保存为单独的 JSON 或 YAML 文件。他们可以使用 OpenAPI 引用机制将文档分解成更小的部分。在 Nest.js 中,开发人员可以使用单独的模块来为他们的应用程序生成 OpenAPI 文档。Nest.js 将使用您在控制器中提供的装饰器来生成有关项目中 API 的尽可能多的信息,而不是手动编写 OpenAPI 文档。当然,它不会一步到位。为此,Nest.js swagger 模块提供了额外的装饰器,您可以使用它们来填补空白。

在本章中,我们将探讨使用 Nest.js Swagger 模块生成 swagger 版本 2 文档。我们将从配置 Nest.js Swagger 模块开始。我们将设置我们的博客示例应用程序以使用 Swagger UI 公开 swagger 文档,并开始探索 Nest.js 装饰器如何影响 swagger 文档。我们还将探索 swagger 模块提供的新装饰器。在本章结束时,您将完全了解 Nest.js 如何生成 swagger 文档。在开始之前,请确保在项目中运行npm install @nestjs/swagger以查看工作示例,记住您可以克隆本书的附带 Git 存储库:

git clone https://github.com/backstopmedia/nest-book-example.git

文档设置

每个 swagger 文档都可以包含一组基本属性,例如应用程序的标题。可以使用DocumentBuilder类上找到的各种公共方法来配置此信息。这些方法都返回文档实例,允许您链式调用尽可能多的方法。在调用build方法之前,请确保完成配置。一旦调用了build方法,文档设置将不再可修改。

const swaggerOptions = new DocumentBuilder()
    .setTitle('Blog Application')
    .setDescription('APIs for the example blog application.')
    .setVersion('1.0.0')
    .setTermsOfService('http://swagger.io/terms/')
    .setContactEmail('admin@example.com')
    .setLicense('Apache 2.0', 'http://www.apache.org/licenses/LICENSE-2.0.html')
    .build();

这些方法用于配置 swagger 文档的info部分。Swagger 规范要求提供titleversion字段,但 Nest.js 将这些值默认为一个空字符串和"1.0.0",分别。如果您的项目有服务条款和许可证,您可以使用setTermsOfServicesetLicense在应用程序中提供这些资源的 URL。

Swagger 文档还可以包含服务器信息。用户、开发人员和 UI 可以使用此信息来了解如何访问文档中描述的 API。

const swaggerOptions = new DocumentBuilder()
    .setHost('localhost:3000')
    .setBasePath('/')
    .setSchemes('http')
    .build();

setHost应仅包含访问 API 的服务器和端口。如果在应用程序中使用setGlobalPrefix为 Nest.js 应用程序配置基本路径,则使用setBasePath在 swagger 文档中设置相同的值。swagger 规范使用schemes数组来描述 API 使用的传输协议。虽然 swagger 规范支持wswss协议以及多个值,但 Nest.js 将该值限制为httphttps。还可以添加元数据和外部文档,以向 swagger 文档的用户提供有关 API 工作方式的其他详细信息。

const swaggerOptions = new DocumentBuilder()
    .setExternalDoc('For more information', 'http://swagger.io')
    .addTag('blog', 'application purpose')
    .addTag('nestjs', 'framework')
    .build();

使用setExternalDoc的第一个参数描述外部文档,第二个参数是文档的 URL。可以使用addTag向文档添加无数个标签。唯一的要求是addTag的第一个参数必须是唯一的。第二个参数应描述标签。最后一个文档设置是用户如何与 API 进行身份验证。

记录身份验证

swagger 规范支持三种类型的身份验证:基本、API 密钥和 Oauth2。Nest.js 提供了两种不同的方法,可以用于自动配置 swagger 文档的身份验证信息,并且可以覆盖一些设置。请记住,这描述了用户如何对您的应用程序进行身份验证。

const swaggerOptions = new DocumentBuilder()
    .addBearerAuth('Authorization', 'header', 'apiKey')
    .build();

如果您的应用程序使用basic身份验证,用户名和密码作为 base64 编码的字符串,或 JSON web 令牌(JWT),您将使用addBearerAuth配置方法。上面的示例使用 Nest.js 的默认值,如果没有传递参数,Nest.js 将使用这些默认值,并确定 API 使用类似 JWT 的 API 密钥在授权标头中。第一个参数应包含应提供身份验证密钥的密钥/标头。如果用户将使用应用程序密钥访问 API,则应使用相同的配置。应用程序密钥通常由公共 API 提供商(如 Google Maps)使用,以限制对 API 的访问并将 API 调用与特定的计费账户关联起来。

const swaggerOptions = new DocumentBuilder()
    .addBearerAuth('token', 'query', 'apiKey')
    .addBearerAuth('appId', 'query', 'apiKey')
    .build();

此示例描述了调用需要身份验证的 API 时必须包含的两个查询参数。第二个参数描述了身份验证密钥应该放在哪里,可以是标头、查询或正文参数。第三个参数是身份验证的类型。使用addBearerAuth时,使用apiKeybasic。除了基本和 API 密钥身份验证外,swagger 还支持记录 Oauth2 身份验证流程。

const swaggerOptions = new DocumentBuilder()
    .addOAuth2('password', 'https://example.com/oauth/authorize', 'https://example.com/oauth/token', {
      read: 'Grants read access',
      write: 'Grants write access',
      admin: 'Grants delete access'
    })
    .build();

addOAuth2方法的第一个参数是 API 用于身份验证的 OAuth2 流。在此示例中,我们使用password流来指示用户应向 API 发送用户名和密码。您还可以使用implicitapplicationaccessCode流。第二个和第三个参数是用户将授权访问 API 和请求刷新令牌的 URL。最后一个参数是应用程序中可用的所有范围及其描述的对象。

对于博客应用程序,我们将保持配置简单,并将配置存储在shared/config目录中的新文件中。有一个中心位置将使我们只需编写一次配置并多次实现。

export const swaggerOptions = new DocumentBuilder()
    .setTitle('Blog Application')
    .setDescription('APIs for the example blog application.')
    .setVersion('1.0.0')
    .setHost('localhost:3000')
    .setBasePath('/')
    .setSchemes('http')
    .setExternalDoc('For more information', 'http://swagger.io')
    .addTag('blog', 'application purpose')
    .addTag('nestjs', 'framework')
    .addBearerAuth('Authorization', 'header', 'apiKey')
    .build();

我们的第一个实现将使用配置和 Nest.js swagger 模块在我们的应用程序中生成两个新的端点:一个用于提供 swagger UI 应用程序,另一个用于提供原始 JSON 格式的 swagger 文档。

Swagger UI

swagger 模块与大多数其他 Nest.js 模块不同。它不是被导入到应用程序的主要 app 模块中,而是在应用程序的主要引导中进行配置。

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    const document = SwaggerModule.createDocument(app, swaggerOptions);
    SwaggerModule.setup('/swagger', app, document);

    await app.listen(process.env.PORT || 3000);
}

在声明 Nest 应用程序并在调用listen方法之前,我们使用上一节配置的 swagger 文档选项和SwaggerModule.createDocument来创建 swagger 文档。Swagger 模块将检查应用程序中的所有控制器,并使用装饰器在内存中构建 swagger 文档。

一旦我们创建了 swagger 文档,我们设置并指示 swagger 模块在指定路径上提供 swagger UI,SwaggerModule.setup('/swagger', app, document)。在幕后,swagger 模块使用swagger-ui-express NodeJS 模块将 swagger 文档转换为完整的 Web UI 应用程序。

示例 Swagger UI 应用程序

上图显示了一个使用我们示例博客应用程序的基本 Swagger UI 应用程序。用于生成 UI 的 JSON 也可以通过将我们为 UI 配置的路径添加-json来获得。在我们的示例中,访问/swagger-json将返回 swagger 文档。这可以与 Swagger Codegen 等代码生成器一起使用。有关 Swagger UI 和 Swagger Codegen 的更多信息,请参阅swagger.io

如果您跟着本书创建了博客应用程序,您可能会发现 Swagger UI 生成的信息不包含应用程序中 API 的很多信息。由于 swagger 文档是使用 Typescript 装饰器元数据构建的,您可能需要修改您的类型或使用 Nest.js swagger 模块中找到的其他装饰器。

API 输入装饰器

Nest.js swagger 模块可以使用@Body@Param@Query@Headers装饰器生成 swagger 文档。然而,根据您编写 API 控制器的方式,swagger 文档可能包含的信息很少。swagger 模块将使用与装饰参数相关联的类型来描述 swagger 文档中 API 期望的参数。为了描述这一点,我们将修改评论 PUT API,使用所有四个装饰器,并通过查看 swagger UI 应用程序来展示这对 swagger 文档的影响。

@Controller('entries/:entryId')
export class CommentController {
    @Put('comments/:commentId')
    public async update(
        @Body() body: UpdateCommentRequest,
        @Param('commentId') comment: string,
        @Query('testQuery') testQuery: string,
        @Headers('testHeader') testHeader: string
    ) {
    }
}

评论放置 Swagger 示例

从示例中,我们可以看到这个 API 卡的标题使用@Controller@Put装饰器的组合来构建 API 的路径。参数部分使用@Body@Param@Query@Headers查询参数构建。我们提供给装饰参数的类型在 Swagger UI 中被用作对用户的提示,说明参数中期望的内容。

评论放置 Swagger 示例

点击 API 卡标题中的试一试按钮会将卡片变成一组输入。这允许用户填写 API 的必需和可选参数,并执行 API 调用。我们将在稍后讨论 API 卡的其余部分。现在,让我们更详细地审查基本参数装饰器。

@Body

您可能已经注意到在我们的示例中,我们用@Body装饰的参数的类型是UpdateCommentRequest。您的应用程序可能已经有这个类,也可能没有。如果没有,让我们现在编写它。

export class UpdateCommentRequest {
    @ApiModelPropertyOptional()
    public body: string;
}

请求类非常基础,使用了 Nest.js swagger 模块中我们将要介绍的第一个装饰器@ApiModelPropertyOptional。这个装饰器通知 swagger 模块,请求类的body属性是一个可选属性,可以在调用 API 时包含在请求体中。这个装饰器实际上是@ApiModelProperty装饰器的快捷方式。我们可以将我们的请求类写成:

export class UpdateCommentRequest {
    @ApiModelProperty({ required: false })
    public body: string;
}

然而,如果属性是可选的,请使用@ApiModelPropertyOptional装饰器来节省一些输入。这两个装饰器都可以接受传递给装饰器的对象中的几个附加属性,进一步定义请求体的数据模型。

  • description:一个字符串,可用于描述模型属性应包含的内容或其用途。

  • required:一个布尔值,指示模型属性是否是必需的。这仅适用于@ApiModelProperty装饰器。

  • type:Nest.js swagger 模块将使用与模型属性关联的类型,或者您可以将type作为任何字符串或类值传递。如果使用isArray属性,则还应使用type属性。此属性还可用于传递 swagger 规范中定义的任何数据类型。

  • isArray:一个布尔值,指示模型属性是否应该接受一组值。如果模型确实接受一组值,请确保在装饰器或 Nest.js swagger 模块中包含此值,以便知道将模型属性表示为数组。

  • collectionFormat:映射到 swagger 规范的collectionFormat设置。这用于描述模型属性数组值的格式应该如何格式化。对于请求体,可能不应该使用此属性。可能的值包括:

  • csv:逗号分隔的值foo,bar

  • ssv:空格分隔的值foo bar

  • tsv:制表符分隔的值foo\tbar

  • pipes:管道分隔的值foo|bar

  • multi:对应于多个参数实例,而不是单个实例的多个值 foo=bar&foo=baz。这仅适用于“query”或“formData”中的参数。

  • default:在 swagger 文档中用于模型属性的默认值。此值还将用于 Swagger UI 中提供的示例。此值的类型取决于模型属性的类型,但可以是字符串、数字,甚至是对象。

  • enum:如果您的模型属性类型是枚举,使用此属性将相同的枚举传递给装饰器,以便 Nest.js swagger 模块可以将这些枚举值注入到 swagger 文档中。

  • format:如果使用 swagger 规范中描述的数据类型的type属性,则可能还需要传递该数据类型的格式。例如,接受具有多个精度点、小数点后的值的字段,type将是integer,但format可能是floatdouble

  • multipleOf:表示传递给模型属性的值应使用模运算符具有零余数的数字。仅当装饰器中的模型属性类型为number或装饰器提供的typeinteger时,才可以设置此属性。

  • maximum:表示传递给模型属性的值应小于或等于给定值才有效的数字。仅当装饰器中的模型属性类型为number或装饰器提供的typeinteger时,才可以设置此属性。此属性不应与exclusiveMaximum一起使用。

  • exclusiveMaximum:表示传递给模型属性的值应小于给定值才有效的数字。仅当装饰器中的模型属性类型为number或装饰器提供的typeinteger时,才可以设置此属性。此属性不应与maximum一起使用。

  • minimum:表示传递给模型属性的值应大于或等于给定值才有效的数字。仅当装饰器中的模型属性类型为number或装饰器提供的typeinteger时,才可以设置此属性。此属性不应与exclusiveMinimum一起使用。

  • exclusiveMinimum:表示传递给模型属性的值应小于给定值才有效的数字。仅当装饰器中的模型属性类型为number或装饰器提供的typeinteger时,才可以设置此属性。此属性不应与minimum一起使用。

  • maxLength:一个数字,表示模型属性中传递的值应该是字符长度少于或等于给定值才能有效。如果在装饰器中设置此属性,则必须是模型属性类型为string或装饰器提供的typestring

  • minLength:一个数字,表示模型属性中传递的值应该是字符长度大于或等于给定值才能有效。如果在装饰器中设置此属性,则必须是模型属性类型为string或装饰器提供的typestring

  • pattern:包含 JavaScript 兼容正则表达式的字符串。模型属性中传递的值应与正则表达式匹配才能有效。如果在装饰器中设置此属性,则必须是模型属性类型为string或装饰器提供的typestring

  • maxItems:一个数字,表示模型属性中传递的值应该是数组长度少于或等于给定值才能有效。如果在装饰器中设置此属性,则必须同时提供值为trueisArray

  • minItems:一个数字,表示模型属性中传递的值应该是数组长度大于或等于给定值才能有效。如果在装饰器中设置此属性,则必须同时提供值为trueisArray

  • uniqueItems:一个数字,表示模型属性中传递的值应包含一组唯一的数组值。如果在装饰器中设置此属性,则必须同时提供值为trueisArray

  • maxProperties:一个数字,表示模型属性中传递的值应该包含少于或等于给定值的属性数量才能有效。如果模型属性类型是类或对象,则在装饰器中设置此属性才有效。

  • minProperties:一个数字,表示模型属性中传递的值应该包含的属性数量大于或等于给定值才能有效。如果模型属性类型是类或对象,则在装饰器中设置此属性才有效。

  • readOnly:一个布尔值,表示模型属性可能在 API 响应体中发送,但不应该在请求体中提供。如果您将使用相同的数据模型类来表示 API 的请求和响应体,请使用此选项。

  • xml:包含表示模型属性格式的 XML 的字符串。仅当模型属性将包含 XML 时使用。

  • example:在 Swagger 文档中放置的示例值。此值还将用于 Swagger UI 中提供的示例,并优先于default装饰器属性值。

已使用@Body装饰器装饰的属性应始终具有类类型。Typescript 接口无法被装饰,也不提供与带装饰器的类相同的元数据。如果在您的应用程序中,任何一个 API 具有带有@Body装饰器和接口类型的属性,则 Nest.js swagger 模块将无法正确创建 Swagger 文档。实际上,Swagger UI 很可能根本不会显示请求体参数。

@Param

在我们的示例中,@Param装饰器包含一个字符串值,指示控制器方法的comment参数使用哪个 URL 参数。当 Nest.js swagger 模块遇到提供的字符串的装饰器时,它能够确定 URL 参数的名称,并将其与方法参数提供的类型一起包含在 swagger 文档中。但是,我们也可以在不向@Param装饰器传递字符串的情况下编写控制器方法,以获取包含所有 URL 参数的对象。如果这样做,Nest.js 只能在我们将类用作comment参数的类型或在控制器方法上使用 Nest.js swagger 模块提供的@ApiImplicitParam装饰器时,才能确定 URL 参数的名称和类型。让我们创建一个新类来描述我们的 URL 参数,并看看它如何影响 swagger UI。

export class UpdateCommentParams {
    @ApiModelProperty()
    public entryId: string;

    @ApiModelProperty()
    public commentId: string;
}

UpdateCommentParams类中,我们创建了一个属性,并使用了@ApiModelProperty装饰器,这样 Nest.js swagger 模块就知道在 swagger 文档中包含属性及其类型。不要尝试将entryId拆分成自己的类并扩展它,因为 Nest.js swagger 模块将无法捕捉扩展类的属性。在类中使用的属性名称与@Controller@Put装饰器中使用的名称匹配也很重要。我们可以修改我们的评论以使用新的类。

@Put('comments/:commentId')
public async update(
    @Body() body: UpdateCommentRequest,
    @Param() params: UpdateCommentParams,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

我们已更改控制器,以便所有路径参数作为对象提供给控制器方法的params参数。

Comment Put Swagger Example

swagger UI 已更新,显示评论 put API 需要两个必需的 URL 参数:entryIdcommentId。如果您将编写使用单个参数在方法控制器中包含所有 URL 参数的 API,您应该期望 Nest.js swagger 模块通知您 URL 参数的首选方法。将类用作 URL 参数的类型不仅通知 Nest.js swagger 模块 URL 参数,还通过提供类型检查和代码自动完成来帮助编写应用程序。

然而,如果您不想创建一个新类来用作 URL 参数的类型,可以使用接口,或者一个或多个 URL 参数在 Nest.js 守卫、中间件或自定义装饰器中,而不在控制器方法中。您仍然可以使用@ApiImplicitParam装饰器通知 Nest.js swagger 模块有关 URL 参数。

@Put('comments/:commentId')
@ApiImplicitParam({ name: 'entryId' })
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

如果需要路径参数才能到达控制器方法,但控制器方法并未专门使用该参数,Nest.js swagger 模块将不会在 swagger 文档中包含它,除非控制器方法使用了@ApiImplicitParam装饰器进行装饰。对于每个必要到达控制器方法的路径参数,使用装饰器一次,但它在控制器本身中并未使用。

@Put('comments/:commentId')
@ApiImplicitParam({ name: 'entryId' })
@ApiImplicitParam({ name: 'commentId' })
public async update(
    @Body() body: UpdateCommentRequest,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

例如,上述控制器作为评论控制器的一部分,需要两个路径参数:entryIdcommentId。由于控制器在方法参数中不包含任何@Param装饰器,因此使用@ApiImplicitParam来描述两个路径参数。

@ApiImplicitParam装饰器可以在传递给装饰器的对象中接受几个附加属性,进一步定义 swagger 文档中的 URL 参数。

  • name:包含 URL 参数名称的字符串。这个装饰器属性是唯一必需的。

  • description:一个字符串,可用于描述 URL 参数应包含什么或用于什么。

  • required:一个布尔值,指示 URL 参数是否是必需的。

  • type:包含 swagger 规范中定义的类型之一的字符串。不应使用类和对象。

@Query

在我们的示例中,@Query装饰器包含一个字符串值,指示控制器方法的testQuery参数使用哪个查询参数。当 Nest.js swagger 模块遇到提供的字符串的装饰器时,它能够确定查询参数的名称,并将其与方法参数提供的类型一起包含在 swagger 文档中。但是,我们也可以编写控制器方法,而不传递字符串给@Query装饰器,以获得包含所有查询参数的对象。如果这样做,Nest.js 只能确定查询参数的名称和类型,如果我们使用类作为testQuery参数的类型或在控制器方法上使用 Nest.js swagger 模块提供的@ApiImplicitQuery装饰器。让我们创建一个新类来描述我们的查询参数,并看看它如何影响 Swagger UI。

export class UpdateCommentQuery {
    @ApiModelPropertyOptional()
    public testQueryA: string;

    @ApiModelPropertyOptional()
    public testQueryB: string;
}

UpdateCommentQuery类中,我们创建了两个属性,并使用@ApiModelPropertyOptional装饰器,以便 Nest.js swagger 模块知道在 swagger 文档中包含这些属性及其类型。我们可以更改我们的评论并将控制器方法更改为使用新类。

@Put('comments/:commentId')
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query() queryParameters: UpdateCommentQuery,
    @Headers('testHeader') testHeader: string
) {
}

我们已更改控制器,以便所有查询参数作为对象提供给控制器方法的queryParameters参数。

Comment Put Swagger Example

Swagger UI 已更新以显示注释,并且put API 接受两个可选的查询参数:testQueryAtestQueryB。如果您将编写将在方法控制器中使用单个参数来保存所有查询参数的 API,那么这应该是您首选的方法,以通知 Nest.js swagger 模块您期望作为查询参数的内容。将类用作查询参数的类型不仅通知 Nest.js swagger 模块查询参数,还通过提供类型检查和代码自动完成来帮助编写应用程序。

但是,如果您不希望创建一个新类来用作查询参数的类型,可以使用接口,或者查询参数在 Nest.js 守卫或中间件中使用自定义装饰器,而不是在控制器方法中使用。您仍然可以使用@ApiImplicitQuery装饰器通知 Nest.js swagger 模块有关查询参数。

@Put('comments/:commentId')
@ApiImplicitQuery({ name: 'testQueryA' })
@ApiImplicitQuery({ name: 'testQueryB' })
public async update(
    @Param('commentId') comment: string,
    @Body() body: UpdateCommentRequest,
    @Query() testQuery: any,
    @Headers('testHeader') testHeader: string
) {
}

如果需要查询参数才能到达控制器方法,但控制器方法没有专门使用查询参数,则 Nest.js swagger 模块将不会在 swagger 文档中包含它,除非控制器方法使用@ApiImplicitQuery装饰器进行装饰。对于每个必要到达控制器方法但在控制器本身中未使用的查询参数,使用装饰器一次。

@Put('comments/:commentId')
@ApiImplicitQuery({ name: 'testQueryA' })
@ApiImplicitQuery({ name: 'testQueryB' })
public async update(
    @Param('commentId') comment: string,
    @Body() body: UpdateCommentRequest,
    @Headers('testHeader') testHeader: string
) {
}

例如,上述控制器需要两个查询参数:testQueryAtestQueryB。由于控制器在方法参数中不包含任何@Query装饰器,因此使用@ApiImplicitQuery来描述两个查询参数。

@ApiImplicitQuery装饰器可以在传递给装饰器的对象中接受几个额外的属性,这些属性将进一步定义 swagger 文档中的查询参数。

  • name:包含查询参数名称的字符串。这个装饰器属性是唯一必需的。

  • description:一个字符串,用于描述查询参数应包含什么或用于什么目的。

  • required:一个布尔值,指示查询参数是否是必需的。

  • type:包含 swagger 规范中定义的类型之一的字符串。不应使用类和对象。

  • isArray:一个布尔值,指示模型属性是否应该采用值数组。如果模型确实采用值数组,请确保在装饰器中包含此值,否则 Nest.js swagger 模块将不知道将模型属性表示为数组。

  • collectionFormat:映射到 swagger 规范collectionFormat设置。这用于描述如何格式化模型属性数组值。可能的值有:

  • csv:逗号分隔的值 foo,bar

  • ssv:空格分隔的值 foo bar

  • tsv:制表符分隔的值 foo\tbar

  • pipes:管道分隔的值 foo|bar

  • multi:对应于多个参数实例,而不是单个实例的多个值 foo=bar&foo=baz。这仅对“query”或“formData”中的参数有效。

@Headers

在我们的示例中,@Headers装饰器包含一个字符串值,指示控制器方法的testHeader参数使用哪个请求头值。当 Nest.js swagger 模块遇到提供的字符串的装饰器时,它能够确定请求头的名称,并将其与方法参数提供的类型一起包含在 swagger 文档中。然而,我们也可以编写控制器方法,而不向@Headers装饰器传递字符串,以获得包含所有请求头的对象。如果我们这样做,Nest.js 只能确定请求头的名称和类型,如果我们使用类作为testHeader参数的类型,或者在控制器方法上使用 Nest.js swagger 模块提供的@ApiImplicitHeader装饰器。让我们创建一个新类来描述我们的查询参数,并看看它如何影响 swagger UI。

export class UpdateCommentHeaders {
    @ApiModelPropertyOptional()
    public testHeaderA: string;

    @ApiModelPropertyOptional()
    public testHeaderB: string;
}

UpdateCommentHeaders类中,我们创建了两个属性,并使用@ApiModelPropertyOptional装饰器,以便 Nest.js swagger 模块知道在 swagger 文档中包含这些属性及其类型。我们可以更改我们的评论put控制器方法以使用新类。

@Put('comments/:commentId')
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers() headers: UpdateCommentHeaders
) {
}

我们已更改控制器,以便将控制器方法期望的所有请求参数作为对象提供给控制器方法的queryParameters参数。

评论放置 Swagger 示例

swagger UI 已更新,显示评论put API 需要两个头部:testHeaderAtestHeaderB。如果您将编写使用单个参数在方法控制器中保存所有预期头部的 API,这应该是通知 Nest.js swagger 模块您期望的首选方法。使用类作为预期头部的类型不仅通知 Nest.js swagger 模块头部,还通过提供类型检查和代码自动完成来帮助编写应用程序。

然而,如果您不希望创建一个新类作为预期头部的类型,您可以使用接口,或者头部用于 Nest.js 守卫、中间件或自定义装饰器,而不是在控制器方法中使用。您仍然可以使用@ApiImplicitHeader@ApiImplicitHeaders装饰器通知 Nest.js swagger 模块有关查询参数。

@Put('comments/:commentId')
@ApiImplicitHeader({ name: 'testHeader' })
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers() headers: any
) {
}

如果需要一个头部才能到达控制器方法,但控制器方法没有专门使用头部。除非控制器方法使用@ApiImplicitHeader@ApiImplicitHeaders装饰器进行装饰,否则 Nest.js swagger 模块不会将其包含在 swagger 文档中。对于每个头部使用一次@ApiImplicitHeader装饰器,或者一次使用@ApiImplicitHeaders装饰器来描述所有头部是必要的。这是为了到达控制器方法,但它在控制器本身中没有使用。

@Put('comments/:commentId')
@ApiImplicitHeader({ name: 'testHeaderA' })
@ApiImplicitHeader({ name: 'testHeaderB' })
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
) {
}

@Put('comments/:commentId')
@ApiImplicitHeader([
    { name: 'testHeaderA' },
    { name: 'testHeaderB' }
])
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
) {
}

例如,上述控制器需要两个头部:testHeaderAtestHeaderB。由于控制器方法在方法参数中不包含@Headers装饰器,因此使用@ApiImplicitHeader@ApiImplicitHeaders来描述两个头部。

@ApiImplicitHeader@ApiImplicitHeaders装饰器可以在对象或对象数组中接受几个额外的属性,分别传递给装饰器,以进一步定义 swagger 文档中的查询参数。

  • name:包含标头名称的字符串。这个装饰器属性是唯一必需的。

  • description:一个字符串,可用于描述标头应包含什么或用于什么。

  • required:一个布尔值,指示标头是否是必需的。

注意:@ApiImplicitHeaders装饰器只是使用@ApiImplicitHeader装饰器的快捷方式多次。如果需要描述多个标头,请使用@ApiImplicitHeaders。此外,您不应该使用这些标头来描述身份验证机制。有其他装饰器可以用于此目的。

身份验证

很可能您在某个时候需要在应用程序中设置某种形式的身份验证。博客示例应用程序使用用户名密码组合来验证用户,并提供 JSON Web 令牌以允许用户访问 API。无论您决定如何设置身份验证,有一点是肯定的:您将需要查询参数或标头来维护身份验证状态,并且您很可能会使用 Nest.js 中间件或守卫来检查用户的身份验证状态。您这样做是因为在每个控制器方法中编写该代码会创建大量的代码重复,并且会使每个控制器方法变得复杂。

如果您的应用程序需要身份验证,请确保使用addOAuth2addBearerAuth方法正确配置文档设置。如果您不确定这些方法的作用,请参考文档设置部分。

除了为 swagger 文档设置身份验证方案之外,您还应该在控制器类或控制器方法上使用ApiBearerAuth和/或ApiOAuth2Auth装饰器。当用于整个控制器类时,这些装饰器会通知 Nest.js swagger 模块所有控制器方法都需要身份验证。如果不是所有控制器方法都需要身份验证,则需要装饰那些需要的单个控制器方法。

@Put('comments/:commentId')
@ApiBearerAuth()
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

此示例描述了一个需要持有者令牌才能使用 API 的单个控制器方法 API。

@Put('comments/:commentId')
@ApiOAuth2Auth(['test'])
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

此示例描述了一个需要特定 OAuth2 角色集才能使用 API 的单个控制器方法 API。@ApiOAuth2Auth装饰器接受用户应具有的所有角色的数组,以便访问 API。

这些装饰器与ApiBearerAuthApiOAuth2Auth文档设置一起使用,以构建用户可以输入其凭据(API 密钥或 Oauth 密钥)并选择其角色(如果使用 OAuth2)的表单,位于 swagger UI 内。然后,当用户执行特定 API 时,这些值将传递到适当的位置,即作为查询参数或标头值。

Swagger UI 登录表单

单击 swagger UI 页面顶部的授权按钮将打开授权表单。对于持有者令牌,请登录应用程序并将返回的授权令牌复制到 swagger UI 授权中提供的空间中。令牌应该是Bearer <TOKEN VALUE>的形式。对于 OAuth2 身份验证,请输入您的凭据并选择您要请求的角色。单击授权按钮将保存凭据,以便在 swagger UI 中执行 API 时使用。

API 请求和响应装饰器

到目前为止,我们主要关注装饰控制器,以便 Nest.js swagger 模块可以构建包含我们的 API 期望或可能使用的所有输入的 swagger 文档。Nest.js swagger 模块还包含可以用于描述 API 如何响应以及它期望接收和发送的内容格式的装饰器。这些装饰器有助于在查看 swagger 文档或使用 swagger UI 时形成特定 API 如何工作的完整图像。

我们在示例博客应用中涵盖的所有 API 都遵循接受 JSON 形式输入的典型模式。然而,应用程序可能需要接受不同的输入类型,通常称为 MIME 类型。例如,我们可以允许我们示例博客应用的用户上传头像图像。图像不能轻松地表示为 JSON,因此我们需要构建一个接受image/png输入 MIME 类型的 API。我们可以通过使用@ApiConsumes装饰器确保这些信息存在于我们应用程序的 swagger 文档中。

@Put('comments/:commentId')
@ApiConsumes('image/png')
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

在这个例子中,我们使用了@ApiConsumes装饰器来告知 Nest.js swagger 模块,评论put API 预期接收一个 png 图像。

评论 Put Swagger UI

Swagger UI 现在显示参数内容类型下拉菜单为image/png@ApiConsumes装饰器可以接受任意数量的 MIME 类型作为参数。装饰器中的多个值将导致参数内容类型下拉菜单包含多个值,第一个值始终是默认值。如果控制器专门用于处理特定的 MIME 类型,比如application/json,则可以将@ApiConsumes装饰器放在控制器类上,而不是每个单独的控制器方法上。然而,如果您的 API 将消耗 JSON,可以不使用装饰器,Nest.js swagger 模块将默认 API 为application/json

除了消耗各种 MIME 数据类型外,API 还可以响应各种 MIME 数据类型。例如,我们虚构的头像上传 API 可能会将图像存储在数据库或云存储提供商中。这样的存储位置可能不直接对用户可访问,因此可以创建一个 API 来查找并返回任何用户的头像图像。我们可以使用@ApiProduces装饰器来告知 Nest.js swagger 模块,API 使用image/png MIME 类型返回数据。

@Put('comments/:commentId')
@ApiProduces('image/png')
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

在这个例子中,我们使用了@ApiProduces装饰器来告知 Nest.js swagger 模块,评论put API 预期返回一个 png 图像。

评论 Put Swagger UI

Swagger UI 现在显示响应内容类型下拉菜单为image/png@ApiProduces装饰器可以接受任意数量的 MIME 类型作为参数。装饰器中的多个值将导致响应内容类型下拉菜单包含多个值,第一个值始终是默认值。如果控制器专门用于处理特定的 MIME 类型,比如application/json,则可以将@ApiConsumes装饰器放在控制器类上,而不是每个单独的控制器方法上。然而,如果您的 API 将消耗 JSON,可以不使用装饰器,Nest.js swagger 模块将默认 API 为application/json

请求和响应的 MIME 类型信息在很大程度上可以告知 Swagger 文档的最终使用方式,以及如何使用 API 以及 API 的工作原理。然而,我们并没有完全记录 API 可能会响应的所有内容。例如,API 响应体中包含哪些数据值,以及可能返回的 HTTP 状态码是什么?可以使用@ApiResponse装饰器提供这样的信息。

@ApiResponse装饰器可以放在单个控制器方法上,也可以放在控制器类上。Nest.js swagger 模块将收集控制器类级别的装饰器数据,并将其与控制器方法的装饰器数据配对,以生成每个单独 API 可能产生的可能响应列表。

@Controller('entries/:entryId')
@ApiResponse({
    status: 500,
    description: 'An unknown internal server error occurred'
})
export class CommentController {
    @Put('comments/:commentId')
    @ApiResponse({
        status: 200,
        description: 'The comment was successfully updated',
        type: UpdateCommentResponse
    })
    public async update(
        @Body() body: UpdateCommentRequest,
        @Param('commentId') comment: string,
        @Query('testQuery') testQuery: string,
        @Headers('testHeader') testHeader: string
    ) {
    }
}

在这个例子中,我们装饰了评论控制器,以便所有的 API 都包含一个用于内部服务器错误的通用响应。更新控制器方法已被装饰,以便状态码为200的响应表示评论已成功更新。类型是另一个数据模型,用于向 Nest.js swagger 模块提供有关响应体中各个属性的信息。

export class UpdateCommentResponse {
  @ApiModelPropertyOptional()
  public success?: boolean;
}

UpdateCommentResponse数据模型包含一个可选属性success,可以进一步向 UI 传达评论已成功更新的信息。

评论放置 swagger UI

现在 swagger UI 在 API 卡的响应部分列出了两种可能的响应。使用@ApiResponse装饰器来告知用户关于使用 API 时可能需要处理的不同成功和错误场景。@ApiResponse装饰器可以在传递给它的对象中接受其他属性。

  • status:包含 API 将响应的 HTTP 状态码的数字。这个装饰器属性是唯一必需的。

  • description:一个字符串,可用于描述响应表示什么或者用户在遇到响应时应该如何反应。

  • type:使用数据模型类中 swagger 规范定义的任何数据类型,来告知用户可以在响应体中期望什么。如果使用了isArray属性,它表示响应将是一个包含提供类型的值的数组。

  • isArray:一个布尔值,指示响应体是否包含一系列值。如果响应体将包含一系列值,请确保在装饰器中包含此值,否则 Nest.js swagger 模块将不知道如何表示响应体为一系列值。

API 元数据装饰器

如果你在任何 Nest.js 项目中工作,并且正确地使用我们到目前为止介绍的装饰器装饰所有的控制器和控制器方法,Nest.js swagger 模块生成的 swagger 文档将包含用户理解和使用 API 所需的每一个技术细节。在本章中,我们将介绍的最后两个装饰器只是为 swagger 文档提供更多的元数据。swagger UI 将使用这些元数据来生成更清晰的 UI,但功能不会改变。

我们将要介绍的第一个装饰器是@ApiOperation。不要将这个装饰器与@Put之类的 HTTP 方法装饰器混淆。这个装饰器用于为单个控制器方法提供标题描述和称为operationId的唯一标识符。

@Put('comments/:commentId')
@ApiOperation({
    title: 'Comment Update',
    description: 'Updates a specific comment with new content',
    operationId: 'commentUpdate'
})
public async update(
    @Body() body: UpdateCommentRequest,
    @Param('commentId') comment: string,
    @Query('testQuery') testQuery: string,
    @Headers('testHeader') testHeader: string
) {
}

在这个例子中,我们提供了一个简短的标题和一个更长的评论放置 API 的描述标题应该保持简短,少于 240 个字符,并用于填充 swagger 规范的summary部分。虽然例子中的描述很短,但在你自己的项目中使用详细的描述。这应该传达用户为什么会使用 API 或者通过使用 API 可以实现什么。operationId必须根据 swagger 文档保持唯一。该值可以在各种 swagger 代码生成项目中用来引用特定的 API。

评论放置 swagger UI

在 swagger UI 中,我们可以看到我们传递给@ApiOperation装饰器的值,以及它们如何用来填充 API 卡的附加细节。标题放在 API 路径旁的标题中。描述是标题后面 API 卡中的第一部分信息。我们可以看到,使用长标题描述会对 API 卡标题产生负面影响,但在 API 卡正文中效果非常好。

评论放置 Swagger UI

从整体上看 Swagger UI 应用程序,我们可以看到示例博客应用程序的所有 API 都被分组在一起。虽然这样可以工作,但更好的是根据它们执行的操作或资源(评论、条目或关键字)对 API 进行分组。这就是@ApiUseTags装饰器的用途。

@ApiUseTags装饰器可以放置在控制器类或单个控制器方法上,并且可以接受任意数量的字符串参数。这些值将被放置在 swagger 文档中的每个单独 API 中。

@Controller('entries/:entryId')
@ApiUseTags('comments')
export class CommentController {

}

在这个例子中,我们装饰了评论控制器类,以便所有控制器方法都被赋予comments标签。

评论放置 Swagger UI

Swagger UI 现在使用标签对 API 进行分组。这确保了类似的 API 被分组,并在每个组之间提供一些间距,以产生更美观的 UI。这些组也是可展开和可折叠的,让用户有隐藏他们可能不感兴趣的 API 的选项。

保存 swagger 文档

我们已经介绍了 Nest.js swagger 模块中所有可用的装饰器,以及 Nest.js 中已有的装饰器,以生成 swagger 文档并公开 swagger UI。当您的 API 主要由开发人员在其自己的项目中使用,或者在本地开发服务器或分期环境中测试 API 时,这非常有效。对于主要用于特定前端应用程序的 API,您可能不希望公开 swagger UI 供一般公众使用。在这种情况下,您仍然可以生成 swagger 文档以供存储,并在您自己或您团队的其他项目中使用。

为了实现这一点,我们将编写一个新的 Typescript 文件,可以作为构建链的一部分执行。我们将使用fs-extras NodeJS 模块,使文件写入磁盘变得更简单。

import * as fs from 'fs-extra';

async function writeDoc() {
    const app = await NestFactory.create(AppModule);
    const document = SwaggerModule.createDocument(app, swaggerOptions);

    fs.ensureDirSync(path.join(process.cwd(), 'dist'));
    fs.writeJsonSync(path.join(process.cwd(), 'dist', 'api-doc.json'), document, { spaces: 2 });
}

writeDoc();

您可以将此文件放在项目的根目录或源目录中,并使用 NPM 脚本条目来执行它,或者使用 NodeJS 运行它。示例代码将使用 Nest.js swagger 模块构建 swagger 文档,并使用fs-extras将文档写入dist目录作为 JSON 文件。

总结

在本章中,我们介绍了 Nest.js swagger 模块如何利用您在应用程序中使用的现有装饰器来创建 swagger v2 规范文档。我们还介绍了 Nest.js swagger 模块提供的所有额外装饰器,以增强 swagger 文档中的信息。我们还设置了示例博客应用程序以公开 swagger UI。

使用 Nest.js swagger 模块不仅可以记录应用程序的控制器,还可以为测试应用程序提供 UI。如果您完全记录了应用程序,Swagger UI 可以是一个很好的替代 UI,或者提供一个简单的测试区域,您或您的用户可以使用,而不必在应用程序的真实 UI 中观察网络调用。Swagger UI 也可以是 Postman 等工具的很好替代品。

如果您不希望使用 Swagger UI 或在生产环境中公开您的 swagger 文档,记住您可以始终将文件写入磁盘作为应用程序的单独构建作业。这允许您以多种方式存储和使用文档,尤其是使用 Swagger Codegen。

下一章将带您了解命令查询责任分离(CQRS)。

第十二章:命令查询职责分离(CQRS)

在本书的这一部分,我们已经努力使用 CRUD 模式构建了一个简单的博客应用程序:创建、检索、更新和删除。我们已经非常好地确保服务处理我们的业务逻辑,而我们的控制器只是这些服务的网关。控制器负责验证请求,然后将请求传递给服务进行处理。在这样一个小型应用程序中,CRUD 非常有效。

但是当我们处理可能具有独特和复杂业务逻辑的大型应用程序时会发生什么?或者也许我们希望在后台启动一些逻辑,以便 UI 能够调用 API 而无需等待所有业务逻辑完成。这些是 CQRS 有意义的领域。CQRS 可以用于隔离和分解复杂的业务逻辑,同步或异步地启动该业务逻辑,并组合这些隔离的部分来解决新的业务问题。

Nest.js 通过提供两个单独的流来实现 CQRS 的命令方面:一个命令总线和一个事件总线,还有一些 sagas 的糖。在本章中,我们将解决向博客条目添加关键字元数据的问题。我们当然可以使用 CRUD 模式来做到这一点,但是让 UI 进行多个 API 调用来存储博客条目及其所有关键字,甚至让我们的博客条目模块执行这一操作,都会使 UI 和我们的应用程序的业务逻辑变得复杂。

相反,我们将转换博客条目模块以使用 CQRS 命令,并使用命令总线来执行所有数据持久化,将其从博客条目模块中的服务中移除。我们将为我们的关键字创建一个新的实体和模块。关键字实体将维护最后更新的时间戳和所有关联条目的引用。将创建两个新的 API:一个提供“热门关键字”的列表,另一个提供与关键字关联的所有条目的列表。

为了确保 UI 不会遭受任何性能损失,所有关键字实体操作将以异步方式进行。关键字将以字符串形式存储在博客条目实体上,以便 UI 可以快速引用而无需查询数据库中的关键字表。在开始之前,请确保在项目中运行了npm install @nestjs/cqrs。要查看一个工作示例,记住你可以克隆本书的附带 Git 存储库:

git clone https://github.com/backstopmedia/nest-book-example.git

入口模块命令

为了使围绕入口模型的业务逻辑更容易扩展,我们首先需要将模块服务中更新数据库的方法提取为单独的命令。让我们首先将博客条目的create方法转换为 Nest.js CQRS 风格的命令。

export class CreateEntryCommand implements ICommand {
    constructor(
        public readonly title: string,
        public readonly content: string,
        public readonly userId: number
    ) {}
}

我们的命令是一个简单的对象,实现了ICommand接口。ICommand接口在 Nest.js 内部用于指示对象是一个命令。这个文件通常在我们模块的子目录中创建,模式类似于commands/impl/。现在我们已经完成了一个示例,让我们完成评论模块的其余命令。

export class UpdateEntryCommand implements ICommand {
    constructor(
        public readonly id: number,
        public readonly title: string,
        public readonly content: string
    ) {}
}

export class DeleteEntryCommand implements ICommand {
    constructor(
        public readonly id: number
    ) {}
}

注意更新和删除命令的一些区别?对于更新命令,我们需要知道正在更新的数据库模型。同样,对于删除命令,我们只需要知道要删除的数据库模型的 id。在这两种情况下,拥有userId是没有意义的,因为博客条目永远不会移动到另一个用户,并且userId对博客条目的删除没有影响。

命令处理程序

现在我们有了用于数据库写操作的命令,我们需要一些命令处理程序。每个命令应该以一对一的方式有一个相应的处理程序。命令处理程序很像我们当前的博客条目服务。它将负责所有数据库操作。通常,命令处理程序放在模块的子目录中,类似于commands/handlers

@CommandHandler(CreateEntryCommand)
export class CreateEntryCommandHandler implements ICommandHandler<CreateEntryCommand> {
    constructor(
        @Inject('EntryRepository') private readonly entryRepository: typeof Entry,
        @Inject('SequelizeInstance') private readonly sequelizeInstance
    ) { }

    async execute(command: CreateEntryCommand, resolve: () => void) {
    }
}

命令处理程序是简单的类,具有一个名为execute的方法,负责处理命令。实现ICommandHandler<CreateEntryCommand>接口有助于确保我们正确编写命令处理程序。在我们的示例中,Nest.js 使用@CommandHandler注解来知道这个类是用来处理我们的新CreateEntryCommand命令的。

由于命令处理程序将成为模块服务的替代品,因此命令处理程序还需要访问我们的数据库。这可能会有所不同,取决于您使用的 ORM 以及应用程序的配置方式。实际上,我们的命令处理程序目前并没有做任何事情。事实上,使用它会破坏应用程序,因为我们还没有实现execute方法的细节。

async execute(command: CreateEntryCommand, resolve: () => void) {
    await this.sequelizeInstance.transaction(async transaction => {
        return await this.entryRepository.create<Entry>(command, {
            returning: true,
            transaction
        });
    });

    resolve();
}

如果您正在跟随示例项目,您可能会注意到我们的execute方法几乎与博客条目服务的create方法相似。实际上,命令处理程序的几乎所有代码都是直接从博客条目服务复制而来的。最大的区别是我们不返回一个值。相反,所有命令处理程序的execute方法都将回调方法作为它们的第二个参数。

Nest.js 允许我们对提供给execute方法的回调执行几种不同的操作。在我们的示例中,我们使用 ORM 来创建和保存新的博客条目。一旦事务解决,我们调用resolve回调来让 Nest.js 知道我们的命令已经执行完毕。如果这看起来很熟悉,那是因为在幕后,Nest.js 正在将我们的execute包装在一个 Promise 中,并将 promise 自己的resolve回调作为我们的execute方法的第二个参数传递进去。

请注意,我们的命令处理程序没有传递reject回调。Nest.js 在调用命令处理程序时不执行任何类型的错误处理。由于我们的命令处理程序正在调用 ORM 将数据存储在数据库中,很可能会抛出异常。如果我们当前的命令处理程序发生这种情况,根据使用的 NodeJS 版本,控制台可能会记录UnhandledPromiseRejectionWarning警告,并且 UI 将一直等待 API 返回直到超时。为了防止这种情况,我们应该将命令处理程序逻辑包装在try...catch块中。

async execute(command: CreateEntryCommand, resolve: () => void) {
    try {
        await this.sequelizeInstance.transaction(async transaction => {
            return await this.entryRepository.create<Entry>(command, {
                returning: true,
                transaction
            });
        });
    } catch (error) {

    } finally {
        resolve();
    }
}

请注意,我们在finally块中调用resolve回调。这是为了确保无论结果如何,命令处理程序都将完成执行,API 都将完成处理。但是当我们的 ORM 抛出异常时会发生什么呢?博客条目没有保存到数据库中,但由于 API 控制器不知道发生了错误,它将向 UI 返回一个 200 的 HTTP 状态。为了防止这种情况,我们可以捕获错误并将其作为参数传递给resolve方法。这可能会违反 CQRS 模式,但是让 UI 知道发生了错误要比假设博客条目已保存更好。

async execute(command: CreateEntryCommand, resolve: (error?: Error) => void) {
    let caught: Error;

    try {
        await this.sequelizeInstance.transaction(async transaction => {
            return await this.entryRepository.create<Entry>(command, {
                returning: true,
                transaction
            });
        });
    } catch (error) {
        caught = error
    } finally {
        resolve(caught);
    }
}

注意: Nest.js 没有规定回调方法必须在何时被调用。我们可以在execute方法的开头调用回调。Nest.js 会将处理返回给控制器,因此 UI 会立即更新,并在之后处理execute方法的其余部分。

让我们通过创建命令来处理更新和删除数据库中的博客条目,完成将我们的博客条目模块转换为 CQRS。

@CommandHandler(UpdateEntryCommand)
export class UpdateEntryCommandHandler implements ICommandHandler<UpdateEntryCommand> {
    constructor(
        @Inject('EntryRepository') private readonly entryRepository: typeof Entry,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
        private readonly databaseUtilitiesService: DatabaseUtilitiesService
    ) { }

    async execute(command: UpdateEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                let entry = await this.entryRepository.findById<Entry>(command.id, { transaction });
                if (!entry) throw new Error('The blog entry was not found.');

                entry = this.databaseUtilitiesService.assign(
                    entry,
                    {
                        ...command,
                        id: undefined
                    }
                );
                return await entry.save({
                    returning: true,
                    transaction,
                });
            });
        } catch (error) {
            caught = error
        } finally {
            resolve(caught);
        }
    }
}

我们的UpdateEntryCommand命令的命令处理程序需要对博客条目服务中的内容进行一些更改。由于我们的命令包含了要更新的博客条目的所有数据,包括id,我们需要剥离id并将命令中的其余值应用到实体中,然后将其保存回数据库。就像我们上一个命令处理程序一样,我们使用try...catch来处理错误,并将任何抛出的异常作为参数传递回resolve回调函数。

@CommandHandler(DeleteEntryCommand)
export class DeleteEntryCommandHandler implements ICommandHandler<DeleteEntryCommand> {
    constructor(
        @Inject('EntryRepository') private readonly entryRepository: typeof Entry,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize
    ) { }

    async execute(command: DeleteEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                return await this.entryRepository.destroy({
                    where: { id: command.id },
                    transaction,
                });
            });
        } catch (error) {
            caught = error
        } finally {
            resolve(caught);
        }

        resolve();
    }
}

我们的DeleteEntryCommand的命令处理程序基本上是博客条目服务中delete方法的副本。我们现在有了三个新的命令及其相应的处理程序。剩下的就是将它们连接起来并开始使用它们。在我们这样做之前,我们必须决定在哪里调用这些新命令。

调用命令处理程序

文档和 NodeJS 应用程序中关于关注点分离的一般共识可能会指示我们从博客条目服务中调用我们的命令。这样做会使控制器像现在这样简单,但不会简化服务。或者,我们将采取的方法是减少服务的复杂性,使其严格用于数据检索,并从控制器中调用我们的命令。无论采取哪种路线,利用新命令的第一步是注入 Nest.js 的CommandBus

注意:您计划在哪里使用您的命令,无论是控制器还是服务,对于实现都没有影响。请随意尝试。

@Controller()
export class EntryController {
    constructor(
        private readonly entryService: EntryService,
        private readonly commandBus: CommandBus
    ) { }

    @Post('entries')
    public async create(@User() user: IUser, @Body() body: any, @Res() res) {
        if (!body || (body && Object.keys(body).length === 0)) return res.status(HttpStatus.BAD_REQUEST).send('Missing some information.');

        const error = await this.commandBus.execute(new CreateEntryCommand(
            body.title,
            body.content,
            user.id
        ));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(result);
        } else {
            return res.set('location', `/entries/${result.id}`).status(HttpStatus.CREATED).send();
        }
    }

上面的例子包含了两个关键更改。首先,我们已经将commandBus添加到构造函数中。Nest.js 会为我们注入一个CommandBus的实例到这个变量中。最后一个更改是create控制器方法。我们不再调用博客条目服务中的create方法,而是使用命令总线创建和执行一个新的CreateEntryCommand。博客条目控制器的其余实现细节几乎与create方法的模式相同。

@Controller()
export class EntryController {
    constructor(
        private readonly entryService: EntryService,
        private readonly commandBus: CommandBus
    ) { }

    @Get('entries')
    public async index(@User() user: IUser, @Res() res) {
        const entries = await this.entryService.findAll();
        return res.status(HttpStatus.OK).json(entries);
    }

    @Post('entries')
    public async create(@User() user: IUser, @Body() body: any, @Res() res) {
        if (!body || (body && Object.keys(body).length === 0)) return res.status(HttpStatus.BAD_REQUEST).send('Missing some information.');

        const error = await this.commandBus.execute(new CreateEntryCommand(
            body.title,
            body.content,
            user.id
        ));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(result);
        } else {
            return res.set('location', `/entries/${result.id}`).status(HttpStatus.CREATED).send();
        }
    }

    @Get('entries/:entryId')
    public async show(@User() user: IUser, @Entry() entry: IEntry, @Res() res) {
        return res.status(HttpStatus.OK).json(entry);
    }

    @Put('entries/:entryId')
    public async update(@User() user: IUser, @Entry() entry: IEntry, @Param('entryId') entryId: number, @Body() body: any, @Res() res) {
        if (user.id !== entry.userId) return res.status(HttpStatus.NOT_FOUND).send('Unable to find the entry.');
        const error = await this.commandBus.execute(new UpdateEntryCommand(
            entryId,
            body.title,
            body.content,
            user.id
        ));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(error);
        } else {
            return res.status(HttpStatus.OK).send();
        }
    }

    @Delete('entries/:entryId')
    public async delete(@User() user: IUser, @Entry() entry: IEntry, @Param('entryId') entryId: number, @Res() res) {
        if (user.id !== entry.userId) return res.status(HttpStatus.NOT_FOUND).send('Unable to find the entry.');
        const error = await this.commandBus.execute(new DeleteEntryCommand(entryId));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(error);
        } else {
            return res.status(HttpStatus.OK).send();
        }
    }
}

从这个例子中可以看出,控制器已经更新,所以博客条目服务只用于检索,所有修改方法现在都在命令总线上分发命令。我们需要配置的最后一件事是博客条目模块。为了使这更容易,让我们首先设置一个 Typescript barrel 来将所有处理程序导出为一个单一变量。

export const entryCommandHandlers = [
    CreateEntryCommandHandler,
    UpdateEntryCommandHandler,
    DeleteEntryCommandHandler
];

将 barrel 导入到博客条目模块中,并将模块连接到命令总线。

@Module({
    imports: [CQRSModule, EntryModule],
    controllers: [CommentController],
    components: [commentProvider, CommentService, ...CommentCommandHandlers],
    exports: [CommentService]
})
export class EntryModule implements NestModule, OnModuleInit {
    public constructor(
        private readonly moduleRef: ModuleRef,
        private readonly commandBus: CommandBus
    ) {}

    public onModuleInit() {
        this.commandBus.setModuleRef(this.moduleRef);
        this.commandBus.register(CommentCommandHandlers);
    }
}

为了将我们的模块连接到命令总线,我们将CQRSModule导入到我们的模块定义中,并将ModuleRefCommandBus注入到模块类构造函数中。模块类还需要实现OnModuleInit接口。最后,在onModuleInit生命周期钩子中发生了魔术。Nest.js 将在实例化我们的模块类后立即执行此方法。在方法内部,我们使用setModuleRefregister将博客条目命令处理程序注册到为该模块创建的命令总线中。

注意:如果您跟随并在控制器中实现了命令的调用,您可以从评论服务中删除createupdatedelete方法。

CQRS Comments Flow

上面的图表提供了入口控制器的命令和查询方面如何被划分的可视化表示。当用户发送请求到create控制器方法时,处理是通过 CQRS 命令总线执行的,但仍然使用 ORM 来更新数据库。当用户希望检索所有条目时,入口控制器使用EntryService,然后使用 ORM 来查询数据库。所有命令(CQRS 中的C)现在都通过命令总线处理,而所有查询(CQRS 中的Q)仍然通过入口服务处理。

将关键字与事件链接起来

现在我们已经展示了在 Nest.js CQRS 中创建命令并使用命令总线的基础知识,我们需要解决存储与博客条目关联的关键字。关键字可以在创建博客条目时添加,并在以后删除。我们可以为关键字创建一个新实体,并使条目实体维护与关键字实体的一对多关系。然而,这将需要我们的数据库查找从更多的表中拉取更多的数据,并且发送回 UI 的响应将变得更大。相反,让我们从只将关键字作为 JSON 字符串存储在博客条目实体上开始。为此,我们需要更新博客条目实体并添加一个新字段。

@Table(tableOptions)
export class Entry extends Model<Entry> {

    @Column({
        type: DataType.TEXT,
        allowNull: true,

    })
    public keywords: string;

}

新数据库列的 ORM 定义将取决于您正在使用的 ORM 和数据库服务器。在这里,我们使用TEXT数据类型。这种数据类型在许多不同的数据库服务器中得到广泛支持,并提供了存储数据量的大限制。例如,Microsoft SQL Server 将此字段限制为最多 2³⁰-1 个字符,而 Postgres 则不施加限制。由于我们正在使用具有迁移功能的 ORM,因此我们还需要创建迁移脚本。如果您不确定如何操作,请参考 TypeORM 或 Sequelize 章节。

export async function up(sequelize) {
    // language=PostgreSQL
    await sequelize.query(`
        ALTER TABLE entries ADD COLUMN keywords TEXT;
    `);

    console.log('*keywords column added to entries table*');
}

export async function down(sequelize) {
    // language=PostgreSQL
    await sequelize.query(`
        ALTER TABLE entries DROP COLUMN keywords;
    `);
}

如果您一直在跟进,您的条目数据库表现在应该有一个关键字列。测试博客条目控制器中的index API 现在应返回带有关键字值的对象。我们仍然需要更新博客条目命令、命令处理程序和控制器,以处理新的和更新的博客条目的关键字。

@Controller()
export class EntryController {

    @Post('entries')
    public async create(@User() user: IUser, @Body() body: any, @Res() res) {
        if (!body || (body && Object.keys(body).length === 0)) return res.status(HttpStatus.BAD_REQUEST).send('Missing some information.');

        const error = await this.commandBus.execute(new CreateEntryCommand(
            body.title,
            body.content,
            body.keywords,
            user.id
        ));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(result);
        } else {
            return res.set('location', `/entries/${result.id}`).status(HttpStatus.CREATED).send();
        }
    }

    @Put('entries/:entryId')
    public async update(@User() user: IUser, @Entry() entry: IEntry, @Param('entryId') entryId: number, @Body() body: any, @Res() res) {
        if (user.id !== entry.userId) return res.status(HttpStatus.NOT_FOUND).send('Unable to find the entry.');
        const error = await this.commandBus.execute(new UpdateEntryCommand(
            entryId,
            body.title,
            body.content,
            body.keywords,
            user.id
        ));

        if (error) {
            return res.status(HttpStatus.INTERNAL_SERVER_ERROR).send(error);
        } else {
            return res.status(HttpStatus.OK).send();
        }
    }
}

博客条目控制器将接受关键字作为字符串数组。这将有助于保持 UI 简单,并防止 UI 执行任意字符串解析。

export class CreateEntryCommand implements ICommand, IEntry {
    constructor(
        public readonly title: string,
        public readonly content: string,
        public readonly keywords: string[],
        public readonly userId: number
    ) {}
}

export class UpdateEntryCommand implements ICommand, IEntry {
    constructor(
        public readonly id: number,
        public readonly title: string,
        public readonly content: string,
        public readonly keywords: string[],
        public readonly userId: number
    ) {}
}

CreateEntryCommandUpdateEntryCommand命令已更新以接受新属性keywords。我们保持字符串数组类型,以便将命令的处理转移到命令处理程序。

@CommandHandler(CreateEntryCommand)
export class CreateEntryCommandHandler implements ICommandHandler<CreateEntryCommand> {

    async execute(command: CreateEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                return await this.EntryRepository.create<Entry>({
                    ...command,
                    keywords: JSON.stringify(command.keywords)
                }, {
                    returning: true,
                    transaction
                });
            });
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

@CommandHandler(UpdateEntryCommand)
export class UpdateEntryCommandHandler implements ICommandHandler<UpdateEntryCommand> {

    async execute(command: UpdateEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                let comment = await this.EntryRepository.findById<Entry>(command.id, { transaction });
                if (!comment) throw new Error('The comment was not found.');

                comment = this.databaseUtilitiesService.assign(
                    comment,
                    {
                        ...command,
                        id: undefined,
                        keywords: JSON.stringify(command.keywords)
                    }
                );
                return await comment.save({
                    returning: true,
                    transaction,
                });
            });
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

CreateEntryCommandHandlerUpdateEntryCommandHandler命令处理程序已更新为将关键字字符串数组转换为 JSON 字符串。关键字还需要单独存储在自己的表中,其中包含它们适用于的博客条目列表和最后更新日期。为此,我们需要创建一个新的 Nest.js 模块和实体。我们稍后将回来添加更多功能。首先,创建新实体。

const tableOptions: IDefineOptions = { timestamp: true, tableName: 'keywords' } as IDefineOptions;

@DefaultScope({
    include: [() => Entry]
})
@Table(tableOptions)
export class Keyword extends Model<Keyword> {
    @PrimaryKey
    @AutoIncrement
    @Column(DataType.BIGINT)
    public id: number;

    @Column({
        type: DataType.STRING,
        allowNull: false,
        validate: {
            isUnique: async (value: string, next: any): Promise<any> => {
                const isExist = await Keyword.findOne({ where: { keyword: value } });
                if (isExist) {
                    const error = new Error('The keyword already exists.');
                    next(error);
                }
                next();
            },
        },
    })
    public keyword: string;

    @CreatedAt
    public createdAt: Date;

    @UpdatedAt
    public updatedAt: Date;

    @DeletedAt
    public deletedAt: Date;

    @BelongsToMany(() => Entry, () => KeywordEntry)
    public entries: Entry[];

    @BeforeValidate
    public static validateData(entry: Entry, options: any) {
        if (!options.transaction) throw new Error('Missing transaction.');
    }
}

BelongsToMany装饰器用于将关键字连接到许多不同的博客条目。由于我们使用字符串列来保持查找速度,因此我们不会在博客条目表中放置BelongsToMany列。() => KeywordEntry参数告诉 ORM 我们将使用KeywordEntry实体来存储关联。我们还需要创建实体。

const tableOptions: IDefineOptions = { timestamp: true, tableName: 'keywords_entries', deletedAt: false, updatedAt: false } as IDefineOptions;

@Table(tableOptions)
export class KeywordEntry extends Model<KeywordEntry> {
    @ForeignKey(() => Keyword)
    @Column({
        type: DataType.BIGINT,
        allowNull: false
    })
    public keywordId: number;

    @ForeignKey(() => Entry)
    @Column({
        type: DataType.BIGINT,
        allowNull: false
    })
    public entryId: number;

    @CreatedAt
    public createdAt: Date;
}

我们的 ORM 将使用@ForeignKey装饰器将此数据库表中的条目链接到keywordsentries表。我们还添加了一个createdAt列,以帮助我们找到最新链接到博客条目的关键字。我们将使用此功能创建我们的“热门关键字”列表。接下来,创建迁移脚本以将新表添加到数据库中。

export async function up(sequelize) {
    // language=PostgreSQL
    await sequelize.query(`
        CREATE TABLE "keywords" (
            "id" SERIAL UNIQUE PRIMARY KEY NOT NULL,
            "keyword" VARCHAR(30) UNIQUE NOT NULL,
            "createdAt" TIMESTAMP NOT NULL,
            "updatedAt" TIMESTAMP NOT NULL,
            "deletedAt" TIMESTAMP
        );
        CREATE TABLE "keywords_entries" (
            "keywordId" INTEGER NOT NULL
                CONSTRAINT "keywords_entries_keywordId_fkey"
                REFERENCES keywords
                ON UPDATE CASCADE ON DELETE CASCADE,
            "entryId" INTEGER NOT NULL
                CONSTRAINT "keywords_entries_entryId_fkey"
                REFERENCES entries
                ON UPDATE CASCADE ON DELETE CASCADE,
            "createdAt" TIMESTAMP NOT NULL,
            UNIQUE("keywordId", "entryId")
        );
  `);

    console.log('*Table keywords created!*');
}

export async function down(sequelize) {
    // language=PostgreSQL
    await sequelize.query(`DROP TABLE keywords_entries`);
    await sequelize.query(`DROP TABLE keywords`);
}

我们的迁移脚本在keywords_entries表中包括一个唯一约束,以确保我们不会将相同的关键字和博客条目链接超过一次。entryId列定义的ON DELETE CASCADE部分将确保当我们删除博客条目时,关键字链接也将被删除。这意味着我们不必创建任何代码来处理删除博客条目时取消关键字的链接。请务必将新的数据库实体添加到数据库提供程序中。

export const databaseProvider = {
    provide: 'SequelizeInstance',
    useFactory: async () => {
        let config;
        switch (process.env.NODE_ENV) {
            case 'prod':
            case 'production':
            case 'dev':
            case 'development':
            default:
                config = databaseConfig.development;
        }

        const sequelize = new Sequelize(config);
        sequelize.addModels([User, Entry, Comment, Keyword, KeywordEntry]);
        /* await sequelize.sync(); */
        return sequelize;
    },
};

最后,创建关键字提供程序和模块。

export const keywordProvider = {
    provide: 'KeywordRepository',
    useValue: Keyword,
};

export const keywordEntryProvider = {
    provide: 'KeywordEntryRepository',
    useValue: KeywordEntry
};

@Module({
    imports: [],
    controllers: [],
    components: [keywordProvider, keywordEntryProvider],
    exports: []
})
export class KeywordModule {}

现在我们有了一个可工作的关键字模块,我们可以开始考虑如何构建存储关键字的应用程序逻辑。为了保持在 CQRS 模式内,我们可以在关键字模块中创建新的命令和命令处理程序。然而,Nest.js 对命令总线的所有实例都施加了模块隔离。这意味着命令处理程序必须在执行命令的同一模块中注册。例如,如果我们尝试从博客条目控制器执行关键字命令,Nest.js 将抛出异常,指示没有为该命令注册处理程序。这就是 Nest.js CQRS 中的事件发挥作用的地方。事件总线不是隔离的。事实上,事件总线允许从任何模块执行事件,无论是否为它们注册了处理程序。

关键事件

事件可以被视为具有一些不同之处的命令。除了不是模块范围之外,它们还是异步的,通常由模型或实体分发,并且每个事件可以有任意数量的事件处理程序。这使它们非常适合处理在创建和更新博客条目时对关键字数据库表进行后台更新。

在我们开始编写代码之前,让我们考虑一下我们希望应用程序如何工作。当创建新的博客条目时,应用程序需要通知关键字模块,博客条目已与关键字关联。我们应该让关键字模块决定关键字是否是新的,需要创建,还是已经存在,只需要更新。相同的逻辑应该适用于对博客条目的更新,但是如果我们不尝试确定哪些关键字是新的,哪些已被删除,我们可以使我们的博客条目更新过程更简单。为了支持这两种情况,我们应该创建一个通用事件来更新博客条目的所有关键字链接。

现在我们对我们要完成的逻辑有了基本的理解,我们可以构建事件类。就像命令一样,CQRS 事件功能需要事件的基本类。事件文件通常在我们模块的子目录中创建,模式类似于events/impl/

export class UpdateKeywordLinksEvent implements IEvent {
    constructor(
        public readonly entryId: number,
        public readonly keywords: string[]
    ) { }
}

事件类应该看起来与我们在本章前面编写的命令类非常相似。不同之处在于事件类实现了IEvent接口,让 Nest.js 知道这些类的实例是 CQRS 事件。我们还需要为这些事件设置处理程序。就像命令处理程序一样,我们的事件处理程序将负责所有数据库操作。通常,事件处理程序放在模块的子目录中,类似于events/handlers

@EventsHandler(UpdateKeywordLinksEvent)
export class UpdateKeywordLinksEventHandler implements IEventHandler<UpdateKeywordLinksEvent> {
    constructor(
        @Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
    ) { }

    async handle(event: UpdateKeywordLinksEvent) {
    }
}

事件处理程序是简单的类,只有一个方法handle,负责处理事件。实现IEventHandler<UpdateKeywordLinksEvent>接口有助于确保我们正确编写事件处理程序。在我们的示例中,Nest.js 使用@EventsHandler注解来知道这个类是用来处理我们的新UpdateKeywordLinksEvent事件的。

我们的事件处理程序与命令处理程序相比的一个关键区别是,事件处理程序不会作为第二个参数得到一个回调方法。Nest.js 将异步调用handle方法。它不会等待它完成,也不会尝试捕获任何返回值,也不会捕获或处理调用handle方法可能导致的任何错误。这并不是说我们不应该仍然使用try...catch来防止任何错误导致与 NodeJS 的问题。

对于更新链接事件处理程序,我们应该将逻辑拆分成单独的方法,以使类更容易阅读和管理。让我们编写handle方法,使其循环遍历所有关键字,并确保关键字存在,并且博客条目与关键字关联。最后,我们应该确保博客条目不与事件keywords数组中不存在的任何关键字关联。

@EventsHandler(UpdateKeywordLinksEvent)
export class UpdateKeywordLinksEventHandler implements IEventHandler<UpdateKeywordLinksEvent> {
    constructor(
        @Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
    ) { }

    async handle(event: UpdateKeywordLinksEvent) {
        try {
            await this.sequelizeInstance.transaction(async transaction => {
                let newKeywords: string[] = [];
                let removedKeywords: Keyword[] = [];

                const keywordEntities = await this.keywordRepository.findAll({
                    include: [{ model: Entry, where: { id: event.entryId }}],
                    transaction
                });

                keywordEntities.forEach(keywordEntity => {
                    if (event.keywords.indexOf(keywordEntity.keyword) === -1) {
                        removedKeywords.push(keywordEntity);
                    }
                });

                event.keywords.forEach(keyword => {
                    if (keywordEntities.findIndex(keywordEntity => keywordEntity.keyword === keyword) === -1) {
                        newKeywords.push(keyword)
                    }
                });

                await Promise.all(
                    newKeywords.map(
                        keyword => this.ensureKeywordLinkExists(transaction, keyword, event.entryId)
                    )
                );
                await Promise.all(
                    removedKeywords.map(
                        keyword => keyword.$remove('entries', event.entryId, { transaction })
                    )
                );
            });
        } catch (error) {
            console.log(error);
        }
    }

    async ensureKeywordLinkExists(transaction: Transaction, keyword: string, entryId: number) {
        const keywordEntity = await this.ensureKeywordExists(transaction, keyword);
        await keywordEntity.$add('entries', entryId, { transaction });
    }

    async ensureKeywordExists(transaction: Transaction, keyword: string): Promise<Keyword> {
        const result = await this.keywordRepository.findOrCreate<Keyword>({
            where: { keyword },
            transaction
        });
        return result[0];
    }
}

事件处理程序逻辑从查找博客条目当前链接到的所有关键字开始。我们循环遍历这些关键字,并提取出不在新关键字数组中的任何关键字。为了找到所有新关键字,我们循环遍历事件中的关键字数组,找到不在keywordEntities数组中的关键字。新关键字通过ensureKeywordLinkExists方法进行处理。ensureKeywordLinkExists使用ensureKeywordExists来在关键字数据库表中创建或查找关键字,并将博客条目添加到关键字条目数组中。$add$remove方法由sequelize-typescript提供,用于快速添加和删除博客条目,而无需查询博客条目。所有处理都使用事务来确保任何错误都将取消所有数据库更新。如果发生错误,数据库将变得不同步,但由于我们处理的是元数据,这并不是什么大问题。我们记录错误,以便应用管理员知道他们需要重新同步元数据。

即使我们只有一个事件处理程序,我们仍然应该创建一个 Typescript barrel 来将其导出为数组。这将确保以后添加新事件是一个简单的过程。

export const keywordEventHandlers = [
    UpdateKeywordLinksEventHandler,
    RemoveKeywordLinksEventHandler
];

在关键字模块中导入 barrel 并连接事件总线。

@Module({
    imports: [CQRSModule],
    controllers: [],
    components: [keywordProvider, ...keywordEventHandlers],
    exports: []
})
export class KeywordModule implements OnModuleInit {
    public constructor(
        private readonly moduleRef: ModuleRef,
        private readonly eventBus: EventBus
    ) {}

    public onModuleInit() {
        this.eventBus.setModuleRef(this.moduleRef);
        this.eventBus.register(keywordEventHandlers);
    }
}

在模块中,导入CQRSModule并将ModuleRefEventBus添加为构造函数参数。实现OnModuleInit接口并创建onModuleInit方法。在onModuleInit方法中,我们使用setModuleRef将事件总线的模块引用设置为当前模块,并使用register注册所有事件处理程序。记得也将事件处理程序添加到components数组中,否则 Nest.js 将无法实例化事件处理程序。现在,我们已经编写并链接了关键字模块中的事件和事件处理程序,我们准备开始调用事件以存储和更新数据库中的关键字链接。

调用事件处理程序

事件处理程序是从数据模型中调用的。数据模型通常是表示存储在数据库中的数据的简单类。Nest.js 对数据模型的唯一规定是它们必须扩展AggregateRoot抽象类。根据您使用的 ORM 以及其配置方式,您可能能够重用现有的数据模型来实现此目的,也可能不能。由于我们的示例使用 Sequelize,sequelize-typescript包要求我们的数据模型扩展Model类。在 Typescript 中,类只能扩展另一个类。我们需要为调用我们的事件处理程序创建一个单独的数据模型。

export class EntryModel extends AggregateRoot {
  constructor(private readonly id: number) {
    super();
  }

  updateKeywordLinks(keywords: string[]) {
    this.apply(new UpdateKeywordLinksEvent(this.id, keywords));
  }
}

我们在博客条目模块中创建我们的数据模型,因为我们将在创建和更新博客条目时调用我们的事件。数据模型包含一个名为updateKeywordLinks的方法,用于在创建或更新博客条目时刷新博客条目关键字链接。如果需要新的事件,我们将向模型添加更多方法来处理调用这些事件。updateKeywordLinks方法实例化了我们创建的事件,并调用了AggregateRoot抽象类中的apply方法来应用事件实例。

对于命令,我们直接使用命令总线来execute我们的命令。对于事件,我们采取了一种不太直接的方法,使用EventPublisher将我们的数据模型链接到事件总线,然后调用我们在数据模型中创建的方法来apply事件。让我们更新CreateEntryCommandHandler以更好地了解发生了什么。

@CommandHandler(CreateEntryCommand)
export class CreateEntryCommandHandler implements ICommandHandler<CreateEntryCommand> {
    constructor(
        @Inject('EntryRepository') private readonly EntryRepository: typeof Entry,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
        private readonly eventPublisher: EventPublisher
    ) { }

    async execute(command: CreateEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            const entry = await this.sequelizeInstance.transaction(async transaction => {
                return await this.EntryRepository.create<Entry>({
                    ...command,
                    keywords: JSON.stringify(command.keywords)
                }, {
                    returning: true,
                    transaction
                });
            });

            const entryModel = this.eventPublisher.mergeObjectContext(new EntryModel(entry.id));
            entryModel.updateKeywordLinks(command.keywords);
            entryModel.commit();
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

命令处理程序构造函数已更新为注入 Nest.js 的EventPublisher的实例。EventPublisher有两个我们关心的方法:mergeClassContextmergeObjectContext。这两种方法都可以用来实现相同的结果,只是方式不同。在我们的例子中,我们使用mergeObjectContext将我们的数据模型的新实例与事件总线合并。这为数据模型实例提供了一个publish方法,该方法在抽象的AggregateRoot类中用于在事件总线上publish新事件。

事件永远不会立即分发。当我们调用updateKeywordLinks时,创建的事件将被放入队列中。当我们在我们的数据模型上调用commit方法时,事件队列将被刷新。如果您发现您的事件处理程序没有触发,请确保您已经在您的数据模型上调用了commit方法。

我们可以使用事件发布者的mergeClassContext方法来实现相同的功能。

const Model = this.eventPublisher.mergeClassContext(EntryModel);
const entryModel = new Model(entry.id);
entryModel.updateKeywordLinks(command.keywords);
entryModel.commit();

UpdateEntryCommandHandler命令处理程序也需要进行相同的更新,以便在更新博客条目时更新关键词链接。

@CommandHandler(UpdateEntryCommand)
export class UpdateEntryCommandHandler implements ICommandHandler<UpdateEntryCommand> {
    constructor(
        @Inject('EntryRepository') private readonly EntryRepository: typeof Entry,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
        private readonly databaseUtilitiesService: DatabaseUtilitiesService,
        private readonly eventPublisher: EventPublisher
    ) { }

    async execute(command: UpdateEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                let entry = await this.EntryRepository.findById<Entry>(command.id, { transaction });
                if (!entry) throw new Error('The comment was not found.');

                entry = this.databaseUtilitiesService.assign(
                    entry,
                    {
                        ...command,
                        id: undefined,
                        keywords: JSON.stringify(command.keywords)
                    }
                );
                return await entry.save({
                    returning: true,
                    transaction,
                });
            });

            const entryModel = this.eventPublisher.mergeObjectContext(new EntryModel(command.id));
            entryModel.updateKeywordLinks(command.keywords);
            entryModel.commit();
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

如果您在自己的项目中跟随了这些步骤,现在您应该能够创建或更新一个博客条目,使用新的或现有的关键词,并且在数据库中看到关键词链接被创建、更新和删除。当然,我们可以通过添加一个新的 API 来返回所有关键词和它们链接到的博客条目,使这些更改更容易查看。

CQRS Keywords Flow

上图提供了一个视觉表示,说明了条目命令处理程序如何工作以保持关键词的更新。请注意控制流的单向性。命令处理程序使用条目模型调用事件,然后忘记它。这是 Nest.js CQRS 中事件总线的异步性质。

检索关键词 API

我们需要在关键词模块中创建一个新的控制器和服务,以支持检索关键词。我们希望允许 UI 列出所有关键词,获取特定关键词,并获取“热门关键词”的列表。让我们先创建服务。

@Injectable()
export class KeywordService implements IKeywordService {
    constructor(@Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
                @Inject('KeywordEntryRepository') private readonly keywordEntryRepository: typeof KeywordEntry) { }

    public async findAll(search?: string, limit?: number): Promise<Array<Keyword>> {
        let options: IFindOptions<Keyword> = {};

        if (search) {
            if (!limit || limit < 1 || limit === NaN) {
                limit = 10;
            }

            options = {
                where: {
                    keyword: {
                        [Op.like]: `%${search}%`
                    }
                },
                limit
            }
        }

        return await this.keywordRepository.findAll<Keyword>(options);
    }

    public async findById(id: number): Promise<Keyword | null> {
        return await this.keywordRepository.findById<Keyword>(id);
    }

    public async findHotLinks(): Promise<Array<Keyword>> {
        // Find the latest 5 keyword links
        const latest5 = await this.keywordEntryRepository.findAll<KeywordEntry>({
            attributes: {
                exclude: ['entryId', 'createdAt']
            },
            group: ['keywordId'],
            order: [[fn('max', col('createdAt')), 'DESC']],
            limit: 5
        } as IFindOptions<any>);

        // Find the 5 keywords with the most links
        const biggest5 = await this.keywordEntryRepository.findAll<KeywordEntry>({
            attributes: {
                exclude: ['entryId', 'createdAt']
            },
            group: 'keywordId',
            order: [[fn('count', 'entryId'), 'DESC']],
            limit: 5,
            where: {
                keywordId: {
                    // Filter out keywords that already exist in the latest5
                    [Op.notIn]: latest5.map(keywordEntry => keywordEntry.keywordId)
                }
            }
        } as IFindOptions<any>);

        // Load the keyword table data
        const result = await Promise.all(
            [...latest5, ...biggest5].map(keywordEntry => this.findById(keywordEntry.keywordId))
        );

        return result;
    }
}

findAll方法接受一个可选的搜索字符串和限制,可以用来过滤关键词。UI 可以使用这个来支持关键词搜索自动完成。如果在搜索时未指定限制,服务将自动将结果限制为 10 个项目。findById方法将支持加载单个关键词的所有信息,包括关联的条目。这些方法相对基本,并模仿其他模块的服务中的方法。然而,findHotLinks方法稍微复杂一些。

findHotLinks方法负责返回最近使用的关键词和具有最多链接的博客条目的关键词。为了做到这一点,我们需要将 ORM 提供程序与连接表KeywordEntry数据模型结合起来。连接表包含关键词和博客条目之间的实际链接,以及它们加入的日期。对于latest5,我们按最大的createdAt日期对列表进行排序,以获取最新的关键词列表。biggest5entryId的计数进行排序,以产生一个包含最多链接的博客条目的关键词列表。在这两个列表中,我们按keywordId进行分组,以产生一个唯一关键词的列表,并将结果限制为前五个。为了确保我们不产生重叠的列表,biggest5还包含一个 where 子句,以不包括已经包含在latest5列表中的任何关键词。

一旦我们有了这两个列表,我们就可以重用服务的findById方法来加载所有找到的关键词的完整数据记录。然后返回这个列表,其中具有最新链接的关键词首先按最新到最旧的顺序排列,然后是具有最多链接的关键词,按最多到最少的顺序排列。现在剩下的就是创建一个控制器,这样 UI 就可以利用我们的新查询方法。

注意:请注意as IFindOptions<any>。这是为了解决sequelize-typescript引起的 linting 错误而需要的。您的应用程序可能需要或不需要这个。

@Controller()
export class KeywordController {
    constructor(
        private readonly keywordService: KeywordService
    ) { }

    @Get('keywords')
    public async index(@Query('search') search: string, @Query('limit') limit: string, @Res() res) {
        const keywords = await this.keywordService.findAll(search, Number(limit));
        return res.status(HttpStatus.OK).json(keywords);
    }

    @Get('keywords/hot')
    public async hot(@Res() res) {
        const keywords = await this.keywordService.findHotLinks();
        return res.status(HttpStatus.OK).json(keywords);
    }

    @Get('keywords/:keywordId')
    public async show(@Param('keywordId') keywordId: string, @Res() res) {
        const keyword = await this.keywordService.findById(Number(keywordId));
        return res.status(HttpStatus.OK).json(keyword);
    }
}

控制器包含三种方法,对应于服务中的三种查询方法。在所有三种方法中,我们调用服务中的适当方法,并将结果作为 JSON 返回。请注意,hot方法在show方法之前列出。如果更改此顺序,调用/keywords/hot API 将导致执行show方法。由于 Nest.js 运行在 ExpressJS 之上,我们声明控制器方法的顺序很重要。ExpressJS 将始终执行与 UI 请求的路径匹配的第一个路由控制器。

我们现在有一个应用程序,它使用 Nest.js CQRS 来拆分业务逻辑,并以异步方式实现其中的一部分。该应用程序能够对博客条目的创建和更新做出反应,以改变关键字元数据。所有这些都是通过事件的使用变得可能的。但是还有另一种方法可以实现相同的目标,即使用传奇而不是我们创建的事件处理程序。

使用传奇链接关键字

传奇可以被视为返回命令的特殊事件处理程序。传奇通过利用 RxJS 来接收和对事件总线发布的所有事件做出反应。使用UpdateKeywordLinksEvent事件处理程序,我们可以将工作逻辑上分为两个单独的命令:一个用于创建关键字链接,一个用于删除它们。由于传奇返回命令,因此传奇和命令必须在同一个模块中创建。否则,命令模块作用域将成为一个问题,当我们的传奇尝试返回在不同模块中找到的命令时,Nest.js 将抛出异常。要开始,我们需要设置将替换我们的单一事件处理程序的命令和命令处理程序。

关键词传奇命令

仅仅因为我们使用传奇来执行我们的新命令并不会改变我们编写这些命令和命令处理程序的方式。我们将在关键字模块中将UpdateKeywordLinksEvent拆分为两个单独的命令。

export class LinkKeywordEntryCommand implements ICommand {
    constructor(
        public readonly keyword: string,
        public readonly entryId: number
    ) { }
}

export class UnlinkKeywordEntryCommand implements ICommand {
    constructor(
        public readonly keyword: string,
        public readonly entryId: number
    ) { }
}

命令有两个属性:keywordentryId。命令采用简单的keyword字符串,因为命令处理程序不应假设关键字已经存在于数据库中。entryId已知存在,因为它是UpdateKeywordLinksEvent事件的参数。

@CommandHandler(LinkKeywordEntryCommand)
export class LinkKeywordEntryCommandHandler implements ICommandHandler<LinkKeywordEntryCommand> {
    constructor(
        @Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize
    ) { }

    async execute(command: LinkKeywordEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                const keyword = await this.keywordRepository.findOrCreate({
                    where: {
                        keyword: command.keyword
                    },
                    transaction
                });

                await keyword[0].$add('entries', command.entryId, { transaction });
            });
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

LinkKeywordEntryCommandHandler命令处理程序负责确保关键字存在于数据库中,然后使用sequelize-typescript提供的$add方法,通过其 id 将博客条目链接到关键字。

@CommandHandler(UnlinkKeywordEntryCommand)
export class UnlinkKeywordEntryCommandHandler implements ICommandHandler<UnlinkKeywordEntryCommand> {
    constructor(
        @Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize
    ) { }

    async execute(command: UnlinkKeywordEntryCommand, resolve: (error?: Error) => void) {
        let caught: Error;

        try {
            await this.sequelizeInstance.transaction(async transaction => {
                const keyword = await this.keywordRepository.findOrCreate<Keyword>({
                    where: {
                        keyword: command.keyword
                    },
                    transaction
                });

                await keyword[0].$remove('entries', command.entryId, { transaction });
            });
        } catch (error) {
            caught = error;
        } finally {
            resolve(caught);
        }
    }
}

UnlinkKeywordEntryCommandHandler命令处理程序负责确保关键字存在于数据库中,然后使用sequelize-typescript提供的$remove方法,通过其 id 删除博客条目与关键字的链接。这些命令比UpdateKeywordLinksEventHandler事件处理程序简单得多。它们有一个单一的目的,即链接或取消链接关键字和博客条目。确定要链接和取消链接的关键字的繁重工作是由传奇保留的。不要忘记在关键字模块中连接命令处理程序。

export const keywordCommandHandlers = [
    LinkKeywordEntryCommandHandler,
    UnlinkKeywordEntryCommandHandler
];

@Module({
    imports: [CQRSModule],
    controllers: [KeywordController],
    components: [keywordProvider, keywordEntryProvider, ...keywordEventHandlers, KeywordService, ...keywordCommandHandlers],
    exports: []
})
export class KeywordModule implements OnModuleInit {
    public constructor(
        private readonly moduleRef: ModuleRef,
        private readonly eventBus: EventBus,
        private readonly commandBus: CommandBus
    ) {}

    public onModuleInit() {
        this.commandBus.setModuleRef(this.moduleRef);
        this.commandBus.register(keywordCommandHandlers);
        this.eventBus.setModuleRef(this.moduleRef);
        this.eventBus.register(keywordEventHandlers);
    }
}

就像条目模块一样,我们创建了一个 Typescript 桶来将命令处理程序导出为数组。这将被导入到模块定义中,并使用register方法注册到命令总线。

关键词传奇

传奇始终以组件类内的公共方法编写,以允许依赖注入。通常,您会为希望在其中实现传奇的每个模块创建一个单独的传奇类,但在拆分复杂的业务逻辑时,创建多个类是有意义的。对于更新关键字传奇,我们将需要一个接受UpdateKeywordLinksEvent事件并输出多个LinkKeywordEntryCommandUnlinkKeywordEntryCommand命令的单一传奇方法。

@Injectable()
export class KeywordSagas {
    constructor(
        @Inject('KeywordRepository') private readonly keywordRepository: typeof Keyword,
        @Inject('SequelizeInstance') private readonly sequelizeInstance: Sequelize,
    ) { }

    public updateKeywordLinks(events$: EventObservable<any>) {
        return events$.ofType(UpdateKeywordLinksEvent).pipe(
            mergeMap(event =>
                merge( // From the rxjs package
                    this.getUnlinkCommands(event),
                    this.getLinkCommands(event)
                )
            )
        );
    }
}

KeywordSagas类包含一个单独的 saga updateKeywordLinks,并使用依赖注入来获取关键字存储库和 Sequelize 实例的引用。传递给updateKeywordLinks saga 的参数由 Nest.js CQRS 事件总线提供。EventObservable是 Nest.js CQRS 提供的一个特殊 observable,其中包含ofType方法。我们使用这个方法来过滤events$ observable,这样我们的 saga 只会处理UpdateKeywordLinksEvent事件。如果忘记使用ofType方法,你的 saga 将对应用程序中发布的每个事件都触发。

我们 saga 的剩余部分严格是 RxJS 功能。你可以自由使用任何 RxJS 操作符,只要 saga 发出一个或多个 CQRS 命令。对于我们的 saga,我们将使用mergeMap来展平命令的内部 observable 流。不要在这里使用switchMap,否则由于switchMap在外部 observable 多次触发时会被取消,命令可能会丢失,因为内部 observable 是两个不同 observable 流的合并:this.getUnlinkCommands(event)是一个UnlinkKeywordEntryCommand命令流,this.getLinkCommands(event)是一个LinkKeywordEntryCommand命令流。

private getUnlinkCommands(event: UpdateKeywordLinksEvent) {
    return from(this.keywordRepository.findAll({
        include: [{ model: Entry, where: { id: event.entryId }}]
    })).pipe(
        // Filter keywordEntities so only those being removed are left
        map(keywordEntities =>
            keywordEntities.filter(keywordEntity => event.keywords.indexOf(keywordEntity.keyword) === -1)
        ),
        // Create new commands for each keywordEntity
        map(keywordEntities => keywordEntities.map(keywordEntity => new UnlinkKeywordEntryCommand(keywordEntity.keyword, event.entryId))),
        switchMap(commands => Observable.of(...commands))
    );
}

private getLinkCommands(event: UpdateKeywordLinksEvent) {
    return from(this.keywordRepository.findAll({
        include: [{ model: Entry, where: { id: event.entryId }}]
    })).pipe(
        // Filter keywordEntities so only those being add are left
        map(keywordEntities =>
            event.keywords.filter(keyword => keywordEntities.findIndex(keywordEntity => keywordEntity.keyword === keyword) === -1)
        ),
        // Create new commands for each keyword
        map(keywords => keywords.map(keyword => new LinkKeywordEntryCommand(keyword, event.entryId))),
        switchMap(commands => Observable.of(...commands))
    );
}

getUnlinkCommandsgetLinkCommands方法首先获取现有关键字博客条目链接的列表。我们使用Observable.fromPromise,因为我们需要从这些方法返回一个 observable。两个命令之间的区别在于过滤的方式。在getUnlinkCommands中,我们需要过滤现有关键字博客条目链接的列表,以找到那些不在事件的关键字数组中的链接。我们在getLinkCommands中颠倒逻辑,并过滤事件中的关键字列表,以找到那些尚未链接到博客条目的关键字。最后,我们将数组映射到命令,并使用switchMap(commands => Observable.of(...commands)),这样我们的 observable 流会发出所有命令,而不是一组命令。由于唯一的区别是过滤,我们可以清理一下,这样就不会频繁查询数据库。

public updateKeywordLinks(events$: EventObservable<any>) {
    return events$.ofType(UpdateKeywordLinksEvent).pipe(
        mergeMap(event => this.compileKeywordLinkCommands(event))
    );
}

private compileKeywordLinkCommands(event: UpdateKeywordLinksEvent) {
    return from(this.keywordRepository.findAll({
        include: [{ model: Entry, where: { id: event.entryId }}]
    })).pipe(
        switchMap(keywordEntities =>
            of(
                ...this.getUnlinkCommands(event, keywordEntities),
                ...this.getLinkCommands(event, keywordEntities)
            )
        )
    );
}

private getUnlinkCommands(event: UpdateKeywordLinksEvent, keywordEntities: Keyword[]) {
    return keywordEntities
        .filter(keywordEntity => event.keywords.indexOf(keywordEntity.keyword) === -1)
        .map(keywordEntity => new UnlinkKeywordEntryCommand(keywordEntity.keyword, event.entryId));
}

private getLinkCommands(event: UpdateKeywordLinksEvent, keywordEntities: Keyword[]) {
    return event.keywords
        .filter(keyword => keywordEntities.findIndex(keywordEntity => keywordEntity.keyword === keyword) === -1)
        .map(keyword => new LinkKeywordEntryCommand(keyword, event.entryId));
}

现在我们的 saga 只查询数据库中现有的关键字博客条目链接一次,getUnlinkCommandsgetLinkCommands方法已经大大简化。这些方法现在接受事件和现有关键字博客条目链接列表,并返回需要执行的命令数组。检索现有关键字博客条目链接的繁重工作已经转移到compileKeywordLinkCommands方法。这个方法使用switchMap将数据库中的结果投影到getUnlinkCommandsgetLinkCommands中。仍然使用Observable.of来逐个发出命令数组。现在,创建和更新博客条目将通过 saga 和关键字命令处理所有关键字链接和取消链接。

CQRS 事件 sagas 流程

上图提供了一个视觉表示,展示了我们的新 sagas 如何将数据库更新的处理交还给关键字模块中的命令总线。一旦执行更新关键字链接的事件,saga 会查询数据库以确定要链接和取消链接的关键字,最后返回适当的命令。请记住,命令处理程序包含一个回调方法,因此它并不是显式地异步的。然而,由于它们是从事件总线调用的,任何响应都不会传递回 sage 或入口命令总线。

总结

CQRS 不仅仅是一个 Nest.js 的包。它是一种设计和布局应用程序的模式。它要求你将数据的命令、创建和更新与数据的查询以及应用程序的方面分开。对于小型应用程序,CQRS 可能会增加许多不必要的复杂性,因此并非适用于每个应用程序。对于中型和大型应用程序,CQRS 可以帮助将复杂的业务逻辑分解为更易管理的部分。

Nest.js 提供了两种实现 CQRS 模式的方法,即命令总线和事件总线,以及一些 saga 形式的糖。命令总线将命令执行隔离到每个模块,这意味着命令只能在注册它的同一模块中执行。命令处理程序并不总是异步的,并且限制了应用程序的其他部分对变化的反应。因此,Nest.js 提供了事件总线。事件总线不局限于单个模块,并提供了一种让同一应用程序的不同模块对其他模块发布的事件做出反应的方式。事实上,事件可以有任意数量的处理程序,使业务逻辑可以轻松扩展而无需更改现有代码。

Saga 是对模块内部事件做出反应的一种不同方式。Saga 是一个简单的函数,它监听事件总线上的事件,并通过返回要执行的命令来做出反应。虽然看似简单,但 saga 允许您利用 RxJS 的强大功能来确定应用程序对事件做出反应的方式。就像我们在示例应用程序中所做的那样,saga 并不局限于仅返回一个或一种类型的命令。

下次当您发现自己在编写复杂的代码来执行一些基于用户与应用程序交互的业务逻辑时,请考虑尝试使用 CQRS 模式。模式的复杂性可能会被应用程序业务逻辑的复杂性或最终复杂性所抵消。

在下一章中,我们将研究两种不同类型项目的架构:一个服务器应用程序,以及一个使用Angular universal与 Nest.js 和 Angular 6 的应用程序。

第十三章:架构

现在您知道,Nest.js 基于与 Angular 相同的原则,因此将其结构与 Angular 类似是一个好主意。

在进入文件结构之前,我们将看到一些关于命名和如何结构化不同目录和文件的指南,以便使项目更易读和可维护。

我们将看一下两种不同类型项目的架构:

  • 服务器应用程序

  • 使用 Angular universal 与 Nest.js 和 Angular 6 创建更完整的应用程序

在本章结束时,您应该知道如何为服务器应用程序或具有客户端前端的完整应用程序结构化您的应用程序。

命名约定的样式指南

在这部分,我们将看到可以使用的命名约定,以便具有更好的可维护性和可读性。对于每个装饰器,您应该使用带连字符的名称,后跟一个点和对应的装饰器或对象的名称。

控制器

控制器的命名应遵循以下原则:

user.controller.ts

@Controller()
export class UserController { /* ... */ }

服务

服务的命名应遵循以下原则:

user.service.ts

@Injectable()
export class UserService { /* ... */ }

模块

模块的命名应遵循以下原则:

user.module.ts

@Module()
export class UserModule { /* ... */ }

中间件

中间件的命名应遵循以下原则:

authentication.middleware.ts

@Injectable()
export class AuthenticationMiddleware { /* ... */ }

异常过滤器

异常过滤器的命名应遵循以下原则:

forbidden.exception.ts

export class ForbiddenException { /* ... */ }

管道

管道的命名应遵循以下原则:

validation.pipe.ts

@Injectable()
export class ValidationPipe { /* ... */ }

守卫

守卫的命名应遵循以下原则:

roles.guard.ts

@Injectable()
export class RolesGuard { /* ... */ }

拦截器

拦截器的命名应遵循以下原则:

logging.interceptor.ts

@Injectable()
export class LoggingInterceptor { /* ... */ }

自定义装饰器

自定义装饰器的命名应遵循以下原则:

comment.decorator.ts

export const Comment: (data?: any, ...pipes: Array<PipeTransform<any>>) => {
    ParameterDecorator = createParamDecorator((data, req) => {
        return req.comment;
    }
};

网关

网关的命名应遵循以下原则:

comment.gateway.ts

@WebSocketGateway()
export class CommentGateway {

适配器

适配器的命名应遵循以下原则:

ws.adapter.ts

export class WsAdapter {

单元测试

单元测试的命名应遵循以下原则:

user.service.spec.ts

端到端测试

端到端测试的命名应遵循以下原则:

user.e2e-spec.ts

现在我们已经概述了 Nest.js 提供的工具,并制定了一些命名指南。我们现在可以进入下一部分了。

目录结构

拥有良好结构化的目录文件的项目非常重要,因为这样更易读、易懂且易于使用。

因此,让我们看看如何结构化我们的目录,以便更清晰。您将在以下示例中看到用于存储库的目录文件架构,该架构是使用前一节中描述的命名约定创建的。

服务器架构

对于服务器架构,您将看到一个用于存储库的建议架构,以便有清晰的目录。

完整概述

查看基本文件结构,不要深入细节:

.
├── artillery/
├── scripts/
├── migrations/
├── src/
├── Dockerfile
├── README.md
├── docker-compose.yml
├── migrate.ts
├── nodemon.json
├── package-lock.json
├── package.json
├── tsconfig.json
├── tslint.json
└── yarn.lock

我们有四个文件夹用于存放服务器所需的所有文件:

  • artillery 目录,如果需要,可以包含所有用于测试 API 端点的场景。

  • scripts 目录将包含您在应用程序中需要使用的所有脚本。在我们的情况下,等待 RabbitMQ 使用的端口打开的脚本,以便 Nest.js 应用程序在启动之前等待。

  • migrations 目录存在是因为我们使用了 sequelize,并且编写了一些迁移文件,这些文件存储在该目录中。

  • src 目录,其中包含我们服务器应用的所有代码。

在存储库中,我们还有一个 client 目录。但在这种情况下,它仅用作 WebSocket 使用的示例。

src 目录

src目录将包含所有应用程序模块、配置、网关等。让我们来看看这个目录:

src
├── app.module.ts
├── main.cluster.ts
├── main.ts
├── gateways
│   ├── comment
│   └── user
├── modules
│   ├── authentication
│   ├── comment
│   ├── database
│   ├── entry
│   ├── keyword
│   └── user
└── shared
    ├── adapters
    ├── config
    ├── decorators
    ├── exceptions
    ├── filters
    ├── guards
    ├── interceptors
    ├── interfaces
    ├── middlewares
    ├── pipes
    └── transports

这个目录也必须被良好地构建。为此,我们创建了三个子目录,对应于所有放在gateways目录中的 Web 套接字网关。modules将包含应用程序所需的所有模块。最后,shared将包含所有共享内容,如其名称所示,与所有adaptersconfig文件和自定义装饰器的元素对应,这些元素可以在任何模块中使用而不属于特定的模块。

现在我们将深入研究模块目录。

模块

您的应用程序的主要部分将被构建为一个模块。这个模块将包含许多不同的文件。让我们看看一个模块可以如何构建:

src/modules
├── authentication
│   ├── authentication.controller.ts
│   ├── authentication.module.ts
│   ├── authentication.service.ts
│   ├── passports
│   │   └── jwt.strategy.ts
│   └── tests
│       ├── e2e
│       │   └── authentication.controller.e2e-spec.ts
│       └── unit
│           └── authentication.service.spec.ts
├── comment
│   ├── comment.controller.ts
│   ├── comment.entity.ts
│   ├── comment.module.ts
│   ├── comment.provider.ts
│   ├── comment.service.ts
│   ├── interfaces
│   │   ├── IComment.ts
│   │   ├── ICommentService.ts
│   │   └── index.ts
│   └── tests
│       ├── unit
│       │   └── comment.service.spec.ts
│       └── utilities.ts
├── database
│   ├── database-utilities.service.ts
│   ├── database.module.ts
│   └── database.provider.ts
├── entry
│   ├── commands
│   │   ├── handlers
│   │   │   ├── createEntry.handler.ts
│   │   │   ├── deleteEntry.handler.ts
│   │   │   ├── index.ts
│   │   │   └── updateEntry.handler.ts
│   │   └── impl
│   │       ├── createEntry.command.ts
│   │       ├── deleteEntry.command.ts
│   │       └── updateEntry.command.ts
│   ├── entry.controller.ts
│   ├── entry.entity.ts
│   ├── entry.model.ts
│   ├── entry.module.ts
│   ├── entry.provider.ts
│   ├── entry.service.ts
│   ├── interfaces
│   │   ├── IEntry.ts
│   │   ├── IEntryService.ts
│   │   └── index.ts
│   └── tests
│       ├── unit
│       │   └── entry.service.spec.ts
│       └── utilities.ts
├── keyword
│   ├── commands
│   │   ├── handlers
│   │   │   ├── index.ts
│   │   │   ├── linkKeywordEntry.handler.ts
│   │   │   └── unlinkKeywordEntry.handler.ts
│   │   └── impl
│   │       ├── linkKeywordEntry.command.ts
│   │       └── unlinkKeywordEntry.command.ts
│   ├── events
│   │   ├── handlers
│   │   │   ├── index.ts
│   │   │   └── updateKeywordLinks.handler.ts
│   │   └── impl
│   │       └── updateKeywordLinks.event.ts
│   ├── interfaces
│   │   ├── IKeyword.ts
│   │   ├── IKeywordService.ts
│   │   └── index.ts
│   ├── keyword.controller.ts
│   ├── keyword.entity.ts
│   ├── keyword.module.ts
│   ├── keyword.provider.ts
│   ├── keyword.sagas.ts
│   ├── keyword.service.ts
│   └── keywordEntry.entity.ts
└── user
    ├── interfaces
    │   ├── IUser.ts
    │   ├── IUserService.ts
    │   └── index.ts
    ├── requests
    │   └── create-user.request.ts
    ├── tests
    │   ├── e2e
    │   │   └── user.controller.e2e-spec.ts
    │   ├── unit
    │   │   └── user.service.spec.ts
    │   └── utilities.ts
    ├── user.controller.ts
    ├── user.entity.ts
    ├── user.module.ts
    ├── user.provider.ts
    └── user.service.ts

在我们的存储库中,有许多模块。其中一些还实现了cqrs,它与模块位于同一个目录,因为它涉及到模块并且是其一部分。cqrs部分分为commandsevents目录。模块还可以定义一些接口,这些接口放在单独的interfaces目录中。单独的目录使我们能够更清晰地阅读和理解,而不必将许多不同的文件混在一起。当然,所有涉及模块的测试也包括在它们自己的tests目录中,并分为unite2e

最后,定义模块本身的主要文件,包括可注入对象、控制器和实体,都在模块的根目录中。

在本节中,我们已经看到了如何以更清晰和更易读的方式构建我们的服务器应用程序的结构。现在您知道应该把所有模块放在哪里,以及如何构建一个模块,以及如果使用它们,应该把网关或共享文件放在哪里。

Angular Universal 架构

存储库的 Angular Universal 部分是一个独立的应用程序,使用 Nest.js 服务器和 Angular 6。它将由两个主要目录组成:e2e用于端到端测试,以及包含服务器和客户端的src

让我们首先看一下这个架构的概述:

├── e2e/
├── src/
├── License
├── README.md
├── angular.json
├── package.json
├── tsconfig.json
├── tslint.json
├── udk.container.js
└── yarn.lock

src目录

这个目录将包含app目录,以便使用模块化的 Angular 架构放置我们的客户端内容。此外,我们还将找到environments,它定义了我们是否处于生产模式,并导出常量。这个环境将被生产环境配置替换为生产模式,然后是servershared目录。共享目录允许我们共享一些文件,例如接口,而服务器目录将包含所有服务器应用程序,就像我们在前一节中看到的那样。

但在这种情况下,服务器有些变化,现在看起来是这样的:

├── main.ts
├── app.module.ts
├── environments
│   ├── environment.common.ts
│   ├── environment.prod.ts
│   └── environment.ts
└── modules
    ├── client
    │   ├── client.constants.ts
    │   ├── client.controller.ts
    │   ├── client.module.ts
    │   ├── client.providers.ts
    │   ├── interfaces
    │   │   └── angular-universal-options.interface.ts
    │   └── utils
    │       └── setup-universal.utils.ts
    └── heroes
        ├── heroes.controller.ts
        ├── heroes.module.ts
        ├── heroes.service.ts
        └── mock-heroes.ts

modules 目录将包含所有的 Nest.js 模块,就像我们在前一节中看到的那样。其中一个模块是client模块,将为 Universal 应用程序提供所有必需的资产,并设置初始化程序以设置引擎并提供一些 Angular 配置。

关于environments,这个目录将包含与 Angular 应用程序相关的所有配置路径。这个配置引用了在前一节项目的基础中看到的angular.json文件中配置的项目。

总结

这一章让您以更易理解、易读和易于处理的方式设置应用程序的架构。我们已经看到了如何为服务器应用程序定义架构目录,以及如何使用 Angular Universal 构建完整应用程序。通过这两个示例,您应该能够以更清晰的方式构建自己的项目。

下一章将展示如何在 Nest.js 中使用测试。

第十四章:测试

自动化测试是软件开发的关键部分。尽管它不能(也不打算)取代手动测试和其他质量保证方法。当正确使用时,自动化测试是一个非常有价值的工具,可以避免回归、错误或不正确的功能。

软件开发是一门棘手的学科:尽管许多开发人员试图隔离软件的不同部分,但往往不可避免地,一些拼图的部分会对其他部分产生影响,无论是有意还是无意。

使用自动化测试的主要目标之一是检测新代码可能破坏先前工作功能的错误类型。这些测试被称为回归测试,当作为合并或部署过程的一部分触发时,它们是最有意义的。这意味着,如果自动化测试失败,合并或部署将被中断,从而避免向主代码库或生产环境引入新错误。

自动化测试还可以实现一种被称为测试驱动开发(TDD)的开发工作流程。在遵循 TDD 方法时,自动化测试是事先编写的,作为反映需求的非常具体的案例。新测试编写完成后,开发人员运行所有测试;新测试应该失败,因为尚未编写新代码。在这一点上,新代码必须被编写,以便新测试通过,同时不破坏旧测试。

如果正确执行测试驱动开发方法,可以提高对代码质量和需求符合的信心。它们还可以使重构甚至完整的代码迁移变得不那么冒险。

在本书中,我们将涵盖两种主要类型的自动化测试:单元测试和端到端测试。

单元测试

顾名思义,每个单元测试覆盖一个特定的功能。处理单元测试时最重要的原则是:

  • 隔离;每个组件必须在没有任何其他相关组件的情况下进行测试;它不能受到副作用的影响,同样,它也不能产生任何副作用。

  • 可预测性;每个测试必须产生相同的结果,只要输入不改变。

在许多情况下,遵守这两个原则意味着模拟(即模拟组件依赖的功能)。

工具

与 Angular 不同,Nest.js 没有用于运行测试的“官方”工具集;这意味着我们可以自由设置我们自己的工具,用于在 Nest.js 项目中运行自动化测试。

JavaScript 生态系统中有多个专注于编写和运行自动化单元测试的工具。典型的解决方案涉及使用多个不同的包进行设置,因为这些包在范围上受限(一个用于测试运行,第二个用于断言,第三个用于模拟,甚至可能还有一个用于代码覆盖报告)。

然而,我们将使用 Jest ,这是来自 Facebook 的“一体化”,“零配置”测试解决方案,大大减少了运行自动化测试所需的配置工作量。它还官方支持 TypeScript,因此非常适合 Nest.js 项目!

准备

正如您所期望的,Jest 被分发为一个 npm 包。让我们在我们的项目中安装它。从命令行或终端运行以下命令:

npm install --save-dev jest ts-jest @types/jest

我们正在安装三个不同的 npm 包作为开发依赖项:Jest 本身;ts-jest,它允许我们在 TypeScript 代码中使用 Jest;以及 Jest 的类型定义,对于我们的 IDE 体验做出了宝贵的贡献!

还记得我们提到 Jest 是一个“零配置”测试解决方案吗?这是他们主页上宣称的。不幸的是,这并不完全正确:在我们能够运行测试之前,我们仍然需要定义一些配置。在我们的情况下,这主要是因为我们使用了 TypeScript。另一方面,我们需要编写的配置实际上并不多,所以我们可以将其编写为一个普通的 JSON 对象。

所以,让我们在项目的根文件夹中创建一个名为 nest.json 的新的 JSON 文件。

/nest.json

{
  "moduleFileExtensions": ["js", "ts", "json"],
  "transform": {
    "^.+\\.ts": "<rootDir>/node_modules/ts-jest/preprocessor.js"
  },
  "testRegex": "/src/.*\\.(test|spec).ts",
  "collectCoverageFrom": [
    "src/**/*.ts",
    "!**/node_modules/**",
    "!**/vendor/**"
  ],
  "coverageReporters": ["json", "lcov", "text"]
}

这个小的 JSON 文件设置了以下配置:

  1. .js.ts.json 文件作为我们应用程序的模块(即代码)进行了配置。你可能会认为我们不需要 .js 文件,但事实上,由于 Jest 自身的一些依赖关系,我们的代码没有这个扩展名就无法运行。

  2. 告诉 Jest 使用 ts-jest 包处理扩展名为 .ts 的文件(这个包在之前已经从命令行安装过)。

  3. 指定我们的测试文件将位于 /src 文件夹中,并且将具有 .test.ts.spec.ts 文件扩展名。

  4. 指示 Jest 从 /src 文件夹中的任何 .ts 文件生成代码覆盖报告,同时忽略 node_modulesvendor 文件夹中的内容。此外,生成的覆盖报告格式为 JSONLCOV

最后,在我们开始编写测试之前的最后一步是向你的 package.json 文件中添加一些新的脚本:

{
  ...
  "scripts": {
    ...
    "test": "jest --config=jest.json",
    "test:watch": "jest --watch --config=jest.json",
    ...
  }
}

这三个新的脚本将分别:运行一次测试,以观察模式运行测试(它们将在每次文件保存后运行),以及运行测试并生成代码覆盖报告(将输出到一个 coverage 文件夹中)。

注意: Jest 将其配置作为 package.json 文件中的 jest 属性接收。如果你决定以这种方式做事情,你将需要在你的 npm 脚本中省略 --config=jest.json 参数。

我们的测试环境已经准备好了。如果我们现在在项目文件夹中运行 npm test,我们很可能会看到以下内容:

No tests found
In /nest-book-example
  54 files checked.
  testMatch:  - 54 matches
  testPathIgnorePatterns: /node_modules/ - 54 matches
  testRegex: /src/.*\.(test|spec).ts - 0 matches
Pattern:  - 0 matches
npm ERR! Test failed.  See above for more details.

测试失败了!好吧,其实并没有失败;我们只是还没有写任何测试!现在让我们来写一些测试。

编写我们的第一个测试

如果你已经阅读了本书的更多章节,你可能还记得我们的博客条目以及我们为它们编写的代码。让我们回顾一下 EntryController。根据章节的不同,代码看起来可能是这样的:

/src/modules/entry/entry.controller.ts

import { Controller, Get, Post, Param } from '@nestjs/common';

import { EntriesService } from './entry.service';

@Controller('entries')
export class EntriesController {
  constructor(private readonly entriesSrv: EntriesService) {}

  @Get()
  findAll() {
    return this.entriesSrv.findAll();
  }
  ...
}

请注意,这个控制器是 EntriesService 的一个依赖项。由于我们提到每个组件都必须在隔离中进行测试,我们需要模拟它可能具有的任何依赖项;在这种情况下,是 EntriesService

让我们为控制器的 findAll() 方法编写一个单元测试。我们将使用一个名为 @nestjs/testing 的特殊 Nest.js 包,它将允许我们为测试专门包装我们的服务在一个 Nest.js 模块中。

此外,遵循约定并将测试文件命名为 entry.controller.spec.ts,并将其放在 entry.controller.ts 文件旁边,这样当我们触发测试运行时 Jest 就能正确地检测到它。

/src/modules/entry/entry.controller.spec.ts

import { Test } from '@nestjs/testing';
import { EntriesController } from './entry.controller';
import { EntriesService } from './entry.service';

describe('EntriesController', () => {
  let entriesController: EntriesController;
  let entriesSrv: EntriesService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [EntriesController],
    })
      .overrideComponent(EntriesService)
      .useValue({ findAll: () => null })
      .compile();

    entriesSrv = module.get<EntriesService>(EntriesService);
    entriesController = module.get<EntriesController>(EntriesController);
  });
});

现在让我们仔细看一下测试代码实现了什么。

首先,我们在 describe('EntriesController', () => { 上声明了一个测试套件。我们还声明了一些变量,entriesControllerentriesSrv,分别用来保存被测试的控制器本身以及控制器所依赖的服务。

接下来是 beforeEach 方法。该方法中的代码将在每个测试运行之前执行。在这段代码中,我们为每个测试实例化了一个 Nest.js 模块。请注意,这是一种特殊类型的模块,因为我们使用了来自 @nestjs/testing 包的 Test 类的 .createTestingModule() 方法。因此,让我们把这个模块看作是一个“模拟模块”,它只用于测试目的。

现在是有趣的部分:我们在测试模块中将EntriesController作为控制器包含进来。然后我们继续使用:

.overrideComponent(EntriesService)
.useValue({ findAll: () => null })

这替换了原始的EntryService,它是我们测试的控制器的一个依赖项。这是服务的模拟版本,甚至不是一个类,因为我们不需要它是一个类,而是一个没有参数并返回 null 的findAll方法的对象。

您可以将上述两行代码的结果视为一个空的、愚蠢的服务,它只重复我们以后需要使用的方法,而没有任何实现内部。

最后,.compile()方法是实际实例化模块的方法,因此它绑定到module常量。

一旦模块正确实例化,我们就可以将先前的entriesControllerentriesSrv变量绑定到模块内控制器和服务的实例上。这是通过调用module.get方法实现的。

一旦所有这些初始设置都完成了,我们就可以开始编写一些实际的测试了。让我们实现一个检查我们的控制器中的findAll()方法是否正确返回条目数组的测试,即使我们只有一个条目:

import { Test } from '@nestjs/testing';
import { EntriesController } from './entry.controller';
import { EntriesService } from './entry.service';

describe('EntriesController', () => {
  let entriesController: EntriesController;
  let entriesSrv: EntriesService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [EntriesController],
    })
      .overrideComponent(EntriesService)
      .useValue({ findAll: () => null })
      .compile();

    entriesSrv = module.get<EntriesService>(EntriesService);
    entriesController = module.get<EntriesController>(EntriesController);
  });

  describe('findAll', () => {
    it('should return an array of entries', async () => {
      expect(Array.isArray(await entriesController.findAll())).toBe(true);
    });
  });
});

describe('findAll', () => {行是开始实际测试套件的行。我们期望entriesController.findAll()的解析值是一个数组。这基本上是我们最初编写代码的方式,所以应该可以工作,对吧?让我们用npm test运行测试并检查测试输出。

FAIL  src/modules/entry/entry.controller.spec.ts
  EntriesController
    findAll
      ✕ should return an array of entries (4ms)

  ● EntriesController › findAll › should return an array of entries

    expect(received).toBe(expected) // Object.is equality

    Expected value to be:
      true
    Received:
      false

      30 |       ];
      31 |       // jest.spyOn(entriesSrv, 'findAll').mockImplementation(() => result);
    > 32 |       expect(Array.isArray(await entriesController.findAll())).toBe(true);
      33 |     });
      34 |
      35 |     // it('should return the entries retrieved from the service', async () => {

      at src/modules/entry/entry.controller.spec.ts:32:64
      at fulfilled (src/modules/entry/entry.controller.spec.ts:3:50)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.112s, estimated 2s
Ran all test suites related to changed files.

它失败了... 好吧,当然失败了!记得beforeEach()方法吗?

...
.overrideComponent(EntriesService)
.useValue({ findAll: () => null })
.compile();
...

我们告诉 Nest.js 将服务中的原始findAll()方法替换为另一个只返回null的方法。我们需要告诉 Jest 用返回数组的东西来模拟该方法,以便检查当EntriesService返回一个数组时,控制器实际上也将该结果作为数组返回。

...
describe('findAll', () => {
  it('should return an array of entries', async () => {
    jest.spyOn(entriesSrv, 'findAll').mockImplementationOnce(() => [{}]);
    expect(Array.isArray(await entriesController.findAll())).toBe(true);
  });
});
...

为了模拟服务中的findAll()方法,我们使用了两个 Jest 方法。spyOn()接受一个对象和一个方法作为参数,并开始监视该方法的执行(换句话说,设置一个spy)。mockImplementationOnce(),顾名思义,当下一次调用该方法时改变方法的实现(在这种情况下,我们将其更改为返回一个空对象的数组)。

让我们尝试再次用npm test运行测试:

 PASS  src/modules/entry/entry.controller.spec.ts
  EntriesController
    findAll
      ✓ should return an array of entries (3ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.134s, estimated 2s
Ran all test suites related to changed files.

测试现在通过了,因此您可以确信控制器上的findAll()方法将始终表现自如,并返回一个数组,以便依赖于该输出为数组的其他代码组件不会自己破坏。

如果这个测试在将来的某个时刻开始失败,那将意味着我们在代码库中引入了一个回归。自动化测试的一个很大的好处是,在为时已晚之前,我们将收到有关此回归的通知。

测试相等性

直到这一点,我们可以确定EntriesController.findAll()返回一个数组。我们无法确定它不是一个空对象数组,或者一个布尔值数组,或者只是一个空数组。换句话说,我们可以将该方法重写为findAll() { return []; },测试仍然会通过。

因此,让我们改进我们的测试,以检查该方法是否真的返回了来自服务的输出,而不会搞乱事情。

import { Test } from '@nestjs/testing';
import { EntriesController } from './entry.controller';
import { EntriesService } from './entry.service';

describe('EntriesController', () => {
  let entriesController: EntriesController;
  let entriesSrv: EntriesService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [EntriesController],
    })
      .overrideComponent(EntriesService)
      .useValue({ findAll: () => null })
      .compile();

    entriesSrv = module.get<EntriesService>(EntriesService);
    entriesController = module.get<EntriesController>(EntriesController);
  });

  describe('findAll', () => {
    it('should return an array of entries', async () => {
      jest.spyOn(entriesSrv, 'findAll').mockImplementationOnce(() => [{}]);
      expect(Array.isArray(await entriesController.findAll())).toBe(true);
    });

    it('should return the entries retrieved from the service', async () => {
      const result = [
        {
          uuid: '1234567abcdefg',
          title: 'Test title',
          body:
            'This is the test body and will serve to check whether the controller is properly doing its job or not.',
        },
      ];
      jest.spyOn(entriesSrv, 'findAll').mockImplementationOnce(() => result);

      expect(await entriesController.findAll()).toEqual(result);
    });
  });
});

我们保留了大部分测试文件之前的内容,尽管我们添加了一个新的测试,最后一个测试,在其中:

  • 我们设置了一个包含一个非空对象(result常量)的数组。

  • 我们再次模拟服务的findAll()方法的实现,以返回该result

  • 我们检查控制器在调用时是否确实像原始的那样返回result对象。请注意,我们使用了 Jest 的.toEqual()方法,它与.toBe()不同,它对两个对象的所有属性进行深度相等比较。

这是我们再次运行npm test时得到的结果:

 PASS  src/modules/entry/entry.controller.spec.ts
  EntriesController
    findAll
      ✓ should return an array of entries (2ms)
      ✓ should return the entries retrieved from the service (1ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.935s, estimated 2s
Ran all test suites related to changed files.

我们的两个测试都通过了。我们已经取得了相当大的成就。现在我们有了一个坚实的基础,将测试扩展到尽可能多的测试用例将是一项容易的任务。

当然,我们只为一个控制器编写了一个测试。但测试服务和我们的 Nest.js 应用程序的其余部分的工作方式是相同的。

在测试中覆盖我们的代码

代码自动化中的一个关键方面是代码覆盖报告。因为,你怎么知道你的测试实际上覆盖了尽可能多的测试用例?嗯,答案就是检查代码覆盖率。

如果您希望对您的测试作为回归检测系统有真正的信心,确保它们尽可能多地覆盖功能。让我们想象一下,我们有一个有五个方法的类,我们只为其中两个编写了测试。我们大约覆盖了五分之二的代码,这意味着我们对另外三分之二没有任何了解,也不知道随着代码库的不断增长它们是否仍然有效。

代码覆盖引擎分析我们的代码和测试,并检查测试套件中运行的测试覆盖的行数、语句和分支的数量,返回一个百分比值。

如前几节所述,Jest 已经默认包含代码覆盖报告,您只需要通过向jest命令传递--coverage参数来激活它。

让我们在package.json文件中添加一个脚本,当执行时将生成覆盖报告:

{
  ...
  "scripts": {
    ...
    "test:coverage":"jest --config=jest.json --coverage --coverageDirectory=coverage",
    ...
  }
}

在之前编写的控制器上运行npm run test:coverage,您将看到以下输出:

 PASS  src/modules/entry/entry.controller.spec.ts
  EntriesController
    findAll
      ✓ should return an array of entries (9ms)
      ✓ should return the entries retrieved from the service (2ms)

---------------------|----------|----------|----------|----------|-------------------|
File                 |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
---------------------|----------|----------|----------|----------|-------------------|
All files            |      100 |    66.67 |      100 |      100 |                   |
 entry.controller.ts |      100 |    66.67 |      100 |      100 |                 6 |
---------------------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.62s
Ran all test suites.

为了更好地了解本书中的控制台输出,我们将把控制台输出转换成一个合适的表格。

文件 % 语句 % 分支 % 函数 % 行 未覆盖的行号
所有文件 100 66.67 100 100
entry.controller.ts 100 66.67 100 100 6

我们可以很容易地看到,我们在测试中覆盖了 100%的代码行。这是有道理的,因为我们为控制器中唯一的方法编写了两个测试。

覆盖率低的失败测试

现在想象一下,我们在一个复杂的项目中与几个开发人员同时在同一个基础上工作。还要想象我们的工作流程包括一个持续集成/持续交付流水线,运行在像 Travis CI、CircleCI 甚至 Jenkins 之类的东西上。我们的流水线可能包括一个在合并或部署之前运行我们的自动化测试的步骤,这样如果测试失败,流水线就会中断。

在这个想象中的项目中工作的所有虚构开发人员将会添加(以及重构和删除,但这些情况并不适用于这个例子)新功能(即新代码),但他们可能会忘记对该代码进行适当的测试。那么会发生什么?项目的覆盖百分比值会下降。

为了确保我们仍然可以依赖我们的测试作为回归检测机制,我们需要确保覆盖率永远不会太低。什么是太低?这实际上取决于多个因素:项目和其使用的技术栈、团队等。然而,通常一个很好的经验法则是在每个编码过程迭代中不要让覆盖率值下降。

无论如何,Jest 允许您为测试指定覆盖率阈值:如果值低于该阈值,测试将返回失败即使它们都通过了。这样,我们的 CI/CD 流水线将拒绝合并或部署我们的代码。

覆盖率阈值必须包含在 Jest 配置对象中;在我们的情况下,它位于项目根文件夹中的jest.json文件中。

{
  ...
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  }
}

传递给对象的每个属性的数字都是百分比值;如果低于这个值,测试将失败。

为了演示,让我们以以上设置运行我们的控制器测试。npm run test:coverage返回如下结果:

 PASS  src/modules/entry/entry.controller.spec.ts
  EntriesController
    findAll
      ✓ should return an array of entries (9ms)
      ✓ should return the entries retrieved from the service (1ms)

---------------------|----------|----------|----------|----------|-------------------|
File                 |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
---------------------|----------|----------|----------|----------|-------------------|
All files            |      100 |    66.67 |      100 |      100 |                   |
 entry.controller.ts |      100 |    66.67 |      100 |      100 |                 6 |
---------------------|----------|----------|----------|----------|-------------------|
Jest: "global" coverage threshold for branches (80%) not met: 66.67%
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.282s, estimated 4s
Ran all test suites.
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! nest-book-example@1.0.0 test:coverage: `jest --config=jest.json --coverage --coverageDirectory=coverage`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the nest-book-example@1.0.0 test:coverage script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

正如你所看到的,测试通过了,但是进程以状态 1 失败并返回错误。此外,Jest 报告说"全局"分支覆盖率阈值(80%)未达到:66.67%。我们已成功将不可接受的代码覆盖率远离了我们的主分支或生产环境。

接下来的步骤可能是实现一些端到端测试,以及我们的单元测试,以改进我们的系统。

端到端测试

尽管单元测试根据定义是孤立和独立的,端到端(或 E2E)测试在某种程度上具有相反的功能:它们旨在检查系统作为一个整体的健康状况,并尝试包括尽可能多的解决方案组件。因此,在 E2E 测试中,我们将专注于测试完整的模块,而不是孤立的组件或控制器。

准备工作

幸运的是,我们可以像对单元测试一样使用 Jest 进行 E2E 测试。我们只需要安装supertest npm 包来执行 API 请求并断言它们的结果。通过在控制台中运行npm install --save-dev supertest来安装它。

另外,我们将在项目的根目录下创建一个名为e2e的文件夹。这个文件夹将保存所有的 E2E 测试文件,以及它们的配置文件。

这将带我们到下一步:在e2e文件夹内创建一个名为jest-e2e.json的新文件,内容如下:

{
  "moduleFileExtensions": ["js", "ts", "json"],
  "transform": {
    "^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest/preprocessor.js"
  },
  "testRegex": "/e2e/.*\\.(e2e-test|e2e-spec).ts|tsx|js)$",
  "coverageReporters": ["json", "lcov", "text"]
}

正如你所看到的,新的 E2E 配置对象与单元测试的对象非常相似;主要区别在于testRegex属性,它现在指向/e2e/文件夹中具有.e2e-teste2e.spec文件扩展名的文件。

准备的最后一步将是在我们的package.json文件中包含一个 npm 脚本来运行端到端测试:

{
  ...
  "scripts": {
    ...
    "e2e": "jest --config=e2e/jest-e2e.json --forceExit"
  }
  ...
}

编写端到端测试

使用 Jest 和 Nest.js 编写端到端测试的方式也与我们用于单元测试的方式非常相似:我们使用@nestjs/testing包创建一个测试模块,我们覆盖EntriesService的实现以避免需要数据库,然后我们准备运行我们的测试。

让我们来编写测试的代码。在e2e文件夹内创建一个名为entries的新文件夹,然后在其中创建一个名为entries.e2e-spec.ts的新文件,内容如下:

import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';

import { EntriesModule } from '../../src/modules/entry/entry.module';
import { EntriesService } from '../../src/modules/entry/entry.service';

describe('Entries', () => {
  let app: INestApplication;
  const mockEntriesService = { findAll: () => ['test'] };

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [EntriesModule],
    })
      .overrideComponent(EntriesService)
      .useValue(mockEntriesService)
      .compile();

    app = module.createNestApplication();
    await app.init();
  });

  it(`/GET entries`, () => {
    return request(app.getHttpServer())
      .get('/entries')
      .expect(200)
      .expect({
        data: mockEntriesService.findAll(),
      });
  });

  afterAll(async () => {
    await app.close();
  });
});

让我们回顾一下代码的功能:

  1. beforeAll方法创建一个新的测试模块,在其中导入EntriesModule(我们将要测试的模块),并用非常简单的mockEntriesService常量覆盖EntriesService的实现。一旦完成,它使用.createNestApplication()方法创建一个实际运行的应用程序来进行请求,然后等待其初始化。

  2. '/GET entries'测试使用 supertest 执行对/entries端点的 GET 请求,然后断言该请求的响应状态码是否为200,并且接收到的响应体是否与mockEntriesService常量的值匹配。如果测试通过,这意味着我们的 API 正确地响应了收到的请求。

  3. afterAll方法在所有测试运行完毕时结束了我们创建的 Nest.js 应用程序。这很重要,以避免在下次运行测试时产生副作用。

总结

在本章中,我们探讨了向我们的项目添加自动化测试的重要性以及它带来的好处。

另外,我们开始使用 Jest 测试框架,并学习了如何配置它,以便与 TypeScript 和 Nest.js 无缝使用。

最后,我们回顾了 Nest.js 为我们提供的测试工具,并学习了如何编写测试,包括单元测试和端到端测试,以及如何检查我们的测试覆盖了多少代码的百分比。

在下一章中,我们将介绍使用 Angular Universal 进行服务器端渲染。

第十五章:使用 Angular Universal 进行服务器端渲染

如果您对用于客户端应用程序开发的 Angular 平台不熟悉,值得一看。Nest.js 与 Angular 有着独特的共生关系,因为它们都是用 TypeScript 编写的。这允许在 Nest.js 服务器和 Angular 应用程序之间进行一些有趣的代码共享,因为 Angular 和 Nest.js 都使用 TypeScript,可以在两者之间创建一个共享的包中的类。然后可以将这些类包含在任一应用程序中,并帮助保持在客户端和服务器之间通过 HTTP 请求发送和接收的对象一致。当我们引入 Angular Universal 时,这种关系被提升到另一个层次。Angular Universal 是一种技术,允许在服务器上预渲染您的 Angular 应用程序。这有许多好处,比如:

  1. 为了便于 SEO 目的的网络爬虫。

  2. 提高网站的加载性能。

  3. 提高低性能设备和移动设备上网站的性能。

这种技术称为服务器端渲染,可以非常有帮助,但需要对项目进行一些重构,因为 Nest.js 服务器和 Angular 应用程序是按顺序构建的,当请求获取网页时,Nest.js 服务器实际上会运行 Angular 应用程序本身。这本质上模拟了浏览器中的 Angular 应用程序,包括 API 调用和加载任何动态元素。这个在服务器上构建的页面现在作为静态网页提供给客户端,动态的 Angular 应用程序在后台静默加载。

如果您现在刚开始阅读本书,并希望跟随示例存储库进行操作,可以使用以下命令进行克隆:

git clone https://github.com/backstopmedia/nest-book-example

Angular 是另一个可以写一整本书的主题。我们将使用一个已经由作者之一改编用于本书的 Angular 6 应用程序。原始存储库可以在这里找到。

https://github.com/patrickhousley/nest-angular-universal.git

这个存储库使用了 Nest 5 和 Angular 6,所以进行了一些更改,因为这本书是基于 Nest 4 的。不过不用担心,我们在本章开头展示的主要存储库中包含了一个 Angular Universal 项目。它可以在项目的根目录下的universal文件夹中找到。这是一个独立的 Nest + Angular 项目,而不是将主要存储库适应这本书的 Angular 应用,我们将其隔离出来,以提供一个清晰简洁的示例。

使用 Nest.js 为 Angular Universal 应用提供服务

现在我们将使用 Nest.js 服务器来提供 Angular 应用程序,我们需要将它们编译在一起,这样当我们运行 Nest.js 服务器时,它就知道在哪里查找 Universal 应用程序。在我们的server/src/main.ts文件中,有一些关键的东西需要在那里。在这里我们创建一个bootstrap()函数,然后从下面调用它。

async function bootstrap() {
  if (environment.production) {
    enableProdMode();
  }

  const app = await NestFactory.create(ApplicationModule.moduleFactory());

  if (module.hot) {
    module.hot.accept();
    module.hot.dispose(() => app.close());
  }

  await app.listen(environment.port);
}

bootstrap()
  .then(() => console.log(`Server started on port ${environment.port}`))
  .catch(err => console.error(`Server startup failed`, err));

让我们逐行分析这个函数。

if (environment.production) {
    enableProdMode();
  }

这告诉应用程序为应用程序启用生产模式。在编写 Web 服务器时,生产模式和开发模式之间有许多不同之处,但如果您想在生产环境中运行 Web 服务器,这是必需的。

const app = await NestFactory.create(ApplicationModule.moduleFactory());

这将创建类型为INestApplication的 Nest 应用程序变量,并将在app.module.ts文件中使用ApplicationModule运行。app将是在environment.port端口上运行的 Nest 应用程序的实例,可以在src/server/environment/environment.ts中找到。这里有三个不同的环境文件:

  1. environment.common.ts-正如其名称所示,这个文件在生产和开发构建之间是共用的。它提供了关于在服务器和客户端应用程序中找到打包构建文件的信息和路径。

  2. environment.ts-这是在开发过程中使用的默认环境,并包括environment.common.ts文件中的设置,以及将production: false和上面提到的端口设置为 3000。

  3. environment.prod.ts-这个文件与#2 相似,只是它设置了production: true,并且没有定义端口,而是默认使用默认端口,通常是 8888。

如果我们在本地开发并且想要进行热重载,即如果我们更改文件,则服务器将重新启动,那么我们需要在我们的main.ts文件中包含以下内容。

if (module.hot) {
  module.hot.accept();
  module.hot.dispose(() => app.close());
}

这是在webpack.server.config.ts文件中设置的,基于我们的NODE_ENV环境变量。

最后,要实际启动服务器,调用我们的INestApplication变量上的.listen()函数,并传递一个端口来运行。

await app.listen(environment.port);

然后我们调用bootstrap(),这将运行上面描述的函数。在这个阶段,我们现在有了我们的 Nest 服务器正在运行,并且能够提供 Angular 应用程序并监听 API 请求。

在上面的bootstrap()函数中,当创建INestApplication对象时,我们提供了ApplicationModule。这是应用程序的入口点,处理 Nest 和 Angular Universal 应用程序。在app.module.ts中我们有:

@Module({
  imports: [
    HeroesModule,
    ClientModule.forRoot()
  ],
})
export class ApplicationModule {}

在这里,我们导入了两个 Nest 模块,HeroesModule,它将为英雄之旅应用程序提供 API 端点,以及ClientModule,它是处理 Universal 的模块。ClientModule有很多内容,但我们将重点介绍处理设置 Universal 的主要内容,这是这个模块的代码。

@Module({
  controllers: [ClientController],
  components: [...clientProviders],
})
export class ClientModule implements NestModule {
  constructor(
    @Inject(ANGULAR_UNIVERSAL_OPTIONS)
    private readonly ngOptions: AngularUniversalOptions,
    @Inject(HTTP_SERVER_REF) private readonly app: NestApplication
  ) {}

  static forRoot(): DynamicModule {
    const requireFn = typeof __webpack_require__ === "function" ? __non_webpack_require__ : require;
    const options: AngularUniversalOptions = {
      viewsPath: environment.clientPaths.app,
      bundle: requireFn(join(environment.clientPaths.server, 'main.js'))
    };

    return {
      module: ClientModule,
      components: [
        {
          provide: ANGULAR_UNIVERSAL_OPTIONS,
          useValue: options,
        }
      ]
    };
  }

  configure(consumer: MiddlewareConsumer): void {
    this.app.useStaticAssets(this.ngOptions.viewsPath);
  }
}

我们将从文件顶部的@Module装饰器开始。与常规的 Nest.js 模块(还有 Angular,记得 Nest.js 是受 Angular 启发的吗?)一样,有controllers(用于端点)属性和components(用于服务、提供者和其他我们想要作为此模块一部分的组件)属性。在这里,我们在controllers数组中包括ClientController,在components中包括...clientProviders。这里的三个点(...)本质上意味着“将数组中的每个元素插入到这个数组中”。让我们更详细地解释一下这些。

ClientController

@Controller()
export class ClientController {
  constructor(
    @Inject(ANGULAR_UNIVERSAL_OPTIONS) private readonly ngOptions: AngularUniversalOptions,
  ) { }

  @Get('*')
  render(@Res() res: Response, @Req() req: Request) {
    res.render(join(this.ngOptions.viewsPath, 'index.html'), { req });
  }
}

这与我们学到的任何其他控制器都是一样的,但有一个小小的不同。在 URL 路径/*上,Nest.js 服务器不是提供 API 端点,而是从之前在环境文件中看到的相同的viewsPath中呈现一个 HTML 页面,即index.html

至于clientProoviders数组:

export const clientProviders = [
  {
    provide: 'UNIVERSAL_INITIALIZER',
    useFactory: async (
      app: NestApplication,
      options: AngularUniversalOptions
    ) => await setupUniversal(app, options),
    inject: [HTTP_SERVER_REF, ANGULAR_UNIVERSAL_OPTIONS]
  }
];

这类似于我们在ClientModule的返回语句中定义自己的提供者,但是我们使用useFactory而不是useValue,这将 Nest 应用程序和我们之前定义的AngularUniversalOptions传递给setupUniversal(app, options)函数。我们花了一段时间,但这就是实际创建 Angular Universal 服务器的地方。

setupUniversal(app, options)

export function setupUniversal(
  app: NestApplication,
  ngOptions: AngularUniversalOptions
) {
  const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = ngOptions.bundle;

  app.setViewEngine('html');
  app.setBaseViewsDir(ngOptions.viewsPath);
  app.engine(
    'html',
    ngExpressEngine({
      bootstrap: AppServerModuleNgFactory,
      providers: [
        provideModuleMap(LAZY_MODULE_MAP),
        {
          provide: APP_BASE_HREF,
          useValue: `http://localhost:${environment.port}`
        }
      ]
    })
  );
}

这里调用了三个主要函数:app.setViewEngine()app.setBaseViewDir()app.engine。第一个.setViewEngine()将视图引擎设置为 HTML,以便引擎呈现视图时知道我们正在处理 HTML。第二个.setBaseViewDir()告诉 Nest.js 在哪里找到 HTML 视图,这同样是之前在environment.common.ts文件中定义的。最后一个非常重要,.engine()定义了要使用的 HTML 引擎,在这种情况下,因为我们使用的是 Angular,它是ngExpressEngine,这是 Angular Universal 引擎。在这里阅读更多关于 Universal express-engine 的信息:github.com/angular/universal/tree/master/modules/express-engine。这将bootstrap设置为AppServerModuleNgFactory对象,这将在下一节中讨论。

ClientModule中,我们可以看到在我们在AppliationModule(服务器入口点)中导入ClientModule时调用的.forRoot()函数。基本上,forRoot()定义了一个要返回的模块,以取代最初导入的ClientModule,也称为ClientModule。返回的这个模块有一个单一组件,提供了ANGULAR_UNIVERSAL_OPTIONS,这是一个定义将传递到组件的useValue属性中的对象类型的接口。

ANGULAR_UNIVERSAL_OPTIONS的结构是:

export interface AngularUniversalOptions {
  viewsPath: string;
  bundle: {
    AppServerModuleNgFactory: any,
    LAZY_MODULE_MAP: any
  };
}

由此可见,useValue的值是在forRoot()顶部定义的options的内容。

const options: AngularUniversalOptions = {
  viewsPath: environment.clientPaths.app,
  bundle: requireFn(join(environment.clientPaths.server, 'main.js'))
};

environment.clientPaths.app的值可以在我们之前讨论过的environment.common.ts文件中找到。作为提醒,它指向编译后的客户端代码的位置。也许你会想为什么bundle的值是一个 require 语句,而接口明确表示它应该是这样的结构:

bundle: {
    AppServerModuleNgFactory: any,
    LAZY_MODULE_MAP: any
  };

好吧,如果你追溯这个 require 语句(..表示向上一级目录),那么你会看到我们将bundle属性设置为另一个模块AppServerModule。稍后会讨论这个,但是 Angular 应用程序最终将被提供。

ClientModule中的最后一部分是configure()函数,它将告诉服务器在哪里找到静态资产。

configure(consumer: MiddlewareConsumer): void {
    this.app.useStaticAssets(this.ngOptions.viewsPath);
  }

构建和运行 Universal App

现在你已经设置好了 Nest.js 和 Angular 文件,几乎可以运行项目了。有一些需要你注意的配置文件,可以在示例项目中找到:github.com/backstopmedia/nest-book-example。到目前为止,我们一直在使用nodemon运行项目,这样我们的更改就会在保存项目时反映出来,但是,现在我们正在打包它以提供一个 Angular 应用程序,我们需要使用不同的包来构建服务器。为此,我们选择了udk,这是一个webpack扩展。它既可以构建我们的生产包,也可以启动一个开发服务器,就像nodemon为我们的普通 Nest.js 应用程序所做的那样。熟悉以下配置文件是个好主意:

  1. angular.json-我们的 Angular 配置文件,处理诸如使用哪个环境文件、可以与ng一起使用的命令以及 Angular CLI 命令等事项。

  2. package.json-项目全局依赖和命令文件。该文件定义了生产和开发所需的依赖项,以及命令行工具(如yarnnpm)可用的命令。

  3. tsconfig.server.json-这是全局tsconfig.json文件的扩展,它提供了一些 Angular 编译器选项,比如在哪里找到 Universal 入口点。

总结

就是这样!我们有一个可以玩耍的 Angular Universal 项目。Angular 是一个很棒的客户端框架,最近一直在蓬勃发展。在这一章中只是浅尝辄止,特别是在 Angular 本身方面,还有很多工作可以做。

这是本书的最后一章。我们希望你会兴奋地使用 Nest.js 来创建各种应用程序。

posted @ 2024-05-23 15:56  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报