认证(Authentication)

身份验证是大多数现有应用程序的重要组成部分。有许多不同的方法、策略和方法来处理用户授权。任何项目采用的方法取决于其特定的应用程序要求。本章介绍了几种可以适应各种不同要求的身份验证方法。

passport 是目前最流行的 node.js 认证库,为社区所熟知,并相继应用于许多生产应用中。将此工具与 Nest 框架集成起来非常简单。为了演示,我们将设置 passport-http-bearer 和 passport-jwt 策略。

Passport是最流行的 node.js 身份验证库,为社区所熟知,并成功地应用于许多生产应用程序中。将这个库与使用 @nestjs/passport 模块的 Nest 应用程序集成起来非常简单。在较高级别,Passport 执行一系列步骤以:

  • 通过验证用户的”证”(例如用户名/密码、JSON Web令牌( JWT )或身份提供者的身份令牌)来验证用户的身份。

  • 管理经过身份验证的状态(通过发出可移植的令牌,例如 JWT,或创建一个 Express 会话)

  • 将有关经过身份验证的用户的信息附加到请求对象,以便在路由处理程序中进一步使用

Passport具有丰富的策略生态系统,可实施各种身份验证机制。 尽管概念上很简单,但是您可以选择的 Passport 策略集非常多,并且有很多种类。 Passport 将这些不同的步骤抽象为标准模式,而 @nestjs/passport 模块将该模式包装并标准化为熟悉的Nest构造。

在本章中,我们将使用这些强大而灵活的模块为 RESTful API服务器实现完整的端到端身份验证解决方案。您可以使用这里描述的概念来实现 Passport 策略,以定制您的身份验证方案。您可以按照本章中的步骤来构建这个完整的示例。您可以在这里找到带有完整示例应用程序的存储库。

身份认证

让我们充实一下我们的需求。对于此用例,客户端将首先使用用户名和密码进行身份验证。一旦通过身份验证,服务器将发出 JWT,该 JWT 可以在后续请求的授权头中作为 token发送,以验证身份验证。我们还将创建一个受保护的路由,该路由仅对包含有效 JWT 的请求可访问。

我们将从第一个需求开始:验证用户。然后我们将通过发行 JWT 来扩展它。最后,我们将创建一个受保护的路由,用于检查请求上的有效 JWT

首先,我们需要安装所需的软件包。Passport 提供了一种名为 Passport-local 的策略,它实现了一种用户名/密码身份验证机制,这符合我们在这一部分用例中的需求。

  1. $ npm install --save @nestjs/passport passport passport-local
  2. $ npm install --save-dev @types/passport-local

对于您选择的任何 Passport 策略,都需要 @nestjs/PassportPassport 包。然后,需要安装特定策略的包(例如,passport-jwtpassport-local),它实现您正在构建的特定身份验证策略。此外,您还可以安装任何 Passport策略的类型定义,如上面的 @types/Passport-local 所示,它在编写 TypeScript 代码时提供了帮助。

Passport 策略

现在可以实现身份认证功能了。我们将首先概述用于任何 Passport 策略的流程。将 Passport 本身看作一个框架是有帮助的。框架的优雅之处在于,它将身份验证过程抽象为几个基本步骤,您可以根据实现的策略对这些步骤进行自定义。它类似于一个框架,因为您可以通过提供定制参数(作为 JSON 对象)和回调函数( Passport 在适当的时候调用这些回调函数)的形式来配置它。 @nestjs/passport 模块将该框架包装在一个 Nest 风格的包中,使其易于集成到 Nest 应用程序中。下面我们将使用 @nestjs/passport ,但首先让我们考虑一下 vanilla Passport 是如何工作的。

vanilla Passport 中,您可以通过提供以下两项配置策略:

  1. 组特定于该策略的选项。例如,在 JWT 策略中,您可以提供一个秘令来对令牌进行签名。

  2. “验证回调”,在这里您可以告诉 Passport 如何与您的用户存储交互(在这里您可以管理用户帐户)。在这里,验证用户是否存在(或创建一个新用户),以及他们的凭据是否有效。Passport 库期望这个回调在验证成功时返回完整的用户消息,在验证失败时返回 null(失败定义为用户没有找到,或者在使用 Passport-local 的情况下,密码不匹配)。

使用 @nestjs/passport ,您可以通过扩展 PassportStrategy 类来配置 passport 策略。通过调用子类中的 super() 方法传递策略选项(上面第1项),可以选择传递一个 options 对象。通过在子类中实现 validate() 方法,可以提供verify 回调(上面第2项)。

我们将从生成一个 AuthModule 开始,其中有一个 AuthService :

  1. $ nest g module auth
  2. $ nest g service auth

当我们实现 AuthService 时,我们会发现在 UsersService 中封装用户操作是很有用的,所以现在让我们生成这个模块和服务:

  1. $ nest g module users
  2. $ nest g service users

替换这些生成文件的默认内容,如下所示。对于我们的示例应用程序,UsersService 只是在内存中维护一个硬编码的用户列表,以及一个根据用户名检索用户列表的 find 方法。在真正的应用程序中,这是您使用选择的库(例如 TypeORMSequelizeMongoose等)构建用户模型和持久层。

users/users.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. export type User = any;
  3. @Injectable()
  4. export class UsersService {
  5. private readonly users: User[];
  6. constructor() {
  7. this.users = [
  8. {
  9. userId: 1,
  10. username: 'john',
  11. password: 'changeme',
  12. },
  13. {
  14. userId: 2,
  15. username: 'chris',
  16. password: 'secret',
  17. },
  18. {
  19. userId: 3,
  20. username: 'maria',
  21. password: 'guess',
  22. },
  23. ];
  24. }
  25. async findOne(username: string): Promise<User | undefined> {
  26. return this.users.find(user => user.username === username);
  27. }
  28. }

UsersModule 中,惟一需要做的更改是将 UsersService 添加到 @Module 装饰器的 exports 数组中,以便提供给其他模块外部可见(我们很快将在 AuthService 中使用它)。

users/users.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { UsersService } from './users.service';
  3. @Module({
  4. providers: [UsersService],
  5. exports: [UsersService],
  6. })
  7. export class UsersModule {}

我们的 AuthService 的任务是检索用户并验证密码。为此,我们创建了 validateUser() 方法。在下面的代码中,我们使用 ES6 扩展操作符从 user 对象中提取 password 属性,然后再返回它。稍后,我们将从 Passport 本地策略中调用 validateUser() 方法。

auth/auth.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { UsersService } from '../users/users.service';
  3. @Injectable()
  4. export class AuthService {
  5. constructor(private readonly usersService: UsersService) {}
  6. async validateUser(username: string, pass: string): Promise<any> {
  7. const user = await this.usersService.findOne(username);
  8. if (user && user.password === pass) {
  9. const { password, ...result } = user;
  10. return result;
  11. }
  12. return null;
  13. }
  14. }

?> 当然,在实际的应用程序中,您不会以纯文本形式存储密码。 取而代之的是使用带有加密单向哈希算法的 bcrypt 之类的库。使用这种方法,您只需存储散列密码,然后将存储的密码与输入密码的散列版本进行比较,这样就不会以纯文本的形式存储或暴露用户密码。为了保持我们的示例应用程序的简单性,我们违反了这个绝对命令并使用纯文本。不要在真正的应用程序中这样做!

现在,我们更新 AuthModule 来导入 UsersModule

auth/auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { UsersModule } from '../users/users.module';
  4. @Module({
  5. imports: [UsersModule],
  6. providers: [AuthService],
  7. })
  8. export class AuthModule {}

现在我们可以实现 Passport 本地身份验证策略。在auth文件夹中创建一个名为 local.strategy.ts 文件,并添加以下代码:

auth/local.strategy.ts

  1. import { Strategy } from 'passport-local';
  2. import { PassportStrategy } from '@nestjs/passport';
  3. import { Injectable, UnauthorizedException } from '@nestjs/common';
  4. import { AuthService } from './auth.service';
  5. @Injectable()
  6. export class LocalStrategy extends PassportStrategy(Strategy) {
  7. constructor(private readonly authService: AuthService) {
  8. super();
  9. }
  10. async validate(username: string, password: string): Promise<any> {
  11. const user = await this.authService.validateUser(username, password);
  12. if (!user) {
  13. throw new UnauthorizedException();
  14. }
  15. return user;
  16. }
  17. }

我们遵循了前面描述的所有护照策略。在我们的 passport-local 用例中,没有配置选项,因此我们的构造函数只是调用 super() ,没有 options 对象。

我们还实现了 validate() 方法。对于每个策略,Passport 将使用适当的特定于策略的一组参数调用 verify 函数(使用 @nestjs/Passport 中的 validate() 方法实现)。对于本地策略,Passport 需要一个具有以下签名的 validate() 方法: validate(username: string, password: string): any

大多数验证工作是在我们的 AuthService 中完成的(在 UserService 的帮助下),所以这个方法非常简单。任何 Passport 策略的 validate() 方法都将遵循类似的模式,只是表示凭证的细节方面有所不同。如果找到了用户并且凭据有效,则返回该用户,以便 Passport 能够完成其任务(例如,在请求对象上创建user 属性),并且请求处理管道可以继续。如果没有找到,我们抛出一个异常,让异常层处理它。

通常,每种策略的 validate() 方法的惟一显著差异是如何确定用户是否存在和是否有效。例如,在 JWT 策略中,根据需求,我们可以评估解码令牌中携带的 userId 是否与用户数据库中的记录匹配,或者是否与已撤销的令牌列表匹配。因此,这种子类化和实现特定于策略验证的模式是一致的、优雅的和可扩展的。

我们需要配置 AuthModule 来使用刚才定义的 Passport 特性。更新 auth.module。看起来像这样:

auth/auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { UsersModule } from '../users/users.module';
  4. import { PassportModule } from '@nestjs/passport';
  5. import { LocalStrategy } from './local.strategy';
  6. @Module({
  7. imports: [UsersModule, PassportModule],
  8. providers: [AuthService, LocalStrategy],
  9. })
  10. export class AuthModule {}

内置 Passport 守卫

守卫章节描述了守卫的主要功能:确定请求是否由路由处理程序。这仍然是正确的,我们将很快使用这个标准功能。但是,在使用 @nestjs/passport 模块的情况下,我们还将引入一个新的小问题,这个问题一开始可能会让人感到困惑,现在让我们来讨论一下。从身份验证的角度来看,您的应用程序可以以两种状态存在:

  1. 用户/客户端未登录(未通过身份验证)
  2. 用户/客户端已登录(已通过身份验证)

在第一种情况下(用户没有登录),我们需要执行两个不同的功能:

  • 限制未经身份验证的用户可以访问的路由(即拒绝访问受限制的路由)。 我们将使用熟悉的警卫来处理这个功能,方法是在受保护的路由上放置一个警卫。我们将在这个守卫中检查是否存在有效的 JWT ,所以我们稍后将在成功发出 JWT 之后处理这个守卫。

  • 当以前未经身份验证的用户尝试登录时,启动身份验证步骤。这时我们向有效用户发出 JWT 的步骤。考虑一下这个问题,我们知道需要 POST 用户名/密码凭证来启动身份验证,所以我们将设置 POST /auth/login 路径来处理这个问题。这就提出了一个问题:在这条路由上,我们究竟如何实施“护照-本地”战略?

答案很简单:使用另一种稍微不同类型的守卫。@nestjs/passport 模块为我们提供了一个内置的守卫,可以完成这一任务。这个保护调用 Passport 策略并启动上面描述的步骤(检索凭证、运行verify 函数、创建用户属性等)。

上面列举的第二种情况(登录用户)仅仅依赖于我们已经讨论过的标准类型的守卫,以便为登录用户启用对受保护路由的访问。

登录路由

有了这个策略,我们现在就可以实现一个简单的 /auth/login 路由,并应用内置的守卫来启动护照本地流。 打开 app.controller.ts 文件,并将其内容替换为以下内容:

app.controller.ts

  1. import { Controller, Request, Post, UseGuards } from '@nestjs/common';
  2. import { AuthGuard } from '@nestjs/passport';
  3. @Controller()
  4. export class AppController {
  5. @UseGuards(AuthGuard('local'))
  6. @Post('auth/login')
  7. async login(@Request() req) {
  8. return req.user;
  9. }
  10. }

对于 @useguard(AuthGuard('local')),我们使用的是一个 AuthGuard ,它是在我们扩展护照-本地策略时 @nestjs/passportautomatic 为我们准备的。我们来分析一下。我们的 Passport 本地策略默认名为"local" 。我们在 @UseGuards() 装饰器中引用这个名称,以便将它与护照本地包提供的代码关联起来。这用于消除在应用程序中有多个 Passport 策略时调用哪个策略的歧义(每个策略可能提供一个特定于策略的 AuthGuard )。虽然到目前为止我们只有一个这样的策略,但我们很快就会添加第二个,所以这是消除歧义所需要的。

为了测试我们的路由,我们将 /auth/login 路由简单地返回用户。这还允许我们演示另一个 Passport 特性: Passport 根据从 validate() 方法返回的值自动创建一个 user 对象,并将其作为 req.user 分配给请求对象。稍后,我们将用创建并返回 JWT 的代码替换它。

因为这些是 API 路由,所以我们将使用常用的cURL库来测试它们。您可以使用 UsersService 中硬编码的任何用户对象进行测试。

  1. $ # POST to /auth/login
  2. $ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
  3. $ # result -> {"userId":1,"username":"john"}

如果上述内容可以正常工作,可以通过直接将策略名称传递给AuthGuard()来引入代码库中的魔术字符串。作为替代,我们推荐创建自己的类,如下所示:

auth/local-auth.guard.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { AuthGuard } from '@nestjs/passport';
  3. @Injectable()
  4. export class LocalAuthGuard extends AuthGuard('local') {}
  1. @UseGuards(LocalAuthGuard)
  2. @Post('auth/login')
  3. async login(@Request() req) {
  4. return req.user;
  5. }

JWT 功能

我们已经准备好进入JWT部分的认证系统。让我们回顾并完善我们的需求:

  • 允许用户使用用户名/密码进行身份验证,返回 JWT 以便在后续调用受保护的 API 端点时使用。我们正在努力满足这一要求。为了完成它,我们需要编写发出 JWT 的代码。

  • 创建基于token 的有效JWT 的存在而受保护的API路由。

我们需要安装更多的包来支持我们的 JWT 需求:

  1. $ npm install @nestjs/jwt passport-jwt
  2. $ npm install @types/passport-jwt --save-dev

@nest/jwt 包是一个实用程序包,可以帮助 jwt 操作。passport-jwt 包是实现 JWT 策略的 Passport包,@types/passport-jwt 提供 TypeScript 类型定义。

让我们仔细看看如何处理 POST /auth/login 请求。我们使用护照本地策略提供的内置AuthGuard 来装饰路由。这意味着:

  1. 只有在了用户之后,才会调用路由处理程序

  2. req参数将包含一个用户属性(在passport-local 身份验证流期间由 Passport 填充)

考虑到这一点,我们现在终于可以生成一个真正的 JWT ,并以这种方式返回它。为了使我们的服务保持干净的模块化,我们将在 authService 中生成 JWT 。在auth文件夹中添加 auth.service.ts 文件,并添加 login() 方法,导入JwtService ,如下图所示:

auth/auth.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { UsersService } from '../users/users.service';
  3. import { JwtService } from '@nestjs/jwt';
  4. @Injectable()
  5. export class AuthService {
  6. constructor(
  7. private readonly usersService: UsersService,
  8. private readonly jwtService: JwtService
  9. ) {}
  10. async validateUser(username: string, pass: string): Promise<any> {
  11. const user = await this.usersService.findOne(username);
  12. if (user && user.password === pass) {
  13. const { password, ...result } = user;
  14. return result;
  15. }
  16. return null;
  17. }
  18. async login(user: any) {
  19. const payload = { username: user.username, sub: user.userId };
  20. return {
  21. access_token: this.jwtService.sign(payload),
  22. };
  23. }
  24. }

我们使用 @nestjs/jwt 库,该库提供了一个 sign() 函数,用于从用户对象属性的子集生成 jwt,然后以简单对象的形式返回一个 access_token 属性。注意:我们选择 sub 的属性名来保持我们的 userId 值与JWT 标准一致。不要忘记将 JwtService 提供者注入到 AuthService中。

现在,我们需要更新 AuthModule 来导入新的依赖项并配置 JwtModule

首先,在auth文件夹下创建 auth/constants.ts,并添加以下代码:

auth/constants.ts

  1. export const jwtConstants = {
  2. secret: 'secretKey',
  3. };

我们将使用它在 JWT 签名和验证步骤之间共享密钥。

不要公开公开此密钥。我们在这里这样做是为了清楚地说明代码在做什么,但是在生产系统中,您必须使用适当的措施来保护这个密钥,比如机密库、环境变量或配置服务。

现在,在auth 文件夹下 auth.module.ts,并更新它看起来像这样:

  1. auth/auth.module.tsJS
  2. import { Module } from '@nestjs/common';
  3. import { AuthService } from './auth.service';
  4. import { LocalStrategy } from './local.strategy';
  5. import { UsersModule } from '../users/users.module';
  6. import { PassportModule } from '@nestjs/passport';
  7. import { JwtModule } from '@nestjs/jwt';
  8. import { jwtConstants } from './constants';
  9. @Module({
  10. imports: [
  11. UsersModule,
  12. PassportModule,
  13. JwtModule.register({
  14. secret: jwtConstants.secret,
  15. signOptions: { expiresIn: '60s' },
  16. }),
  17. ],
  18. providers: [AuthService, LocalStrategy],
  19. exports: [AuthService],
  20. })
  21. export class AuthModule {}

我们使用 register() 配置 JwtModule ,并传入一个配置对象。有关 Nest JwtModule 的更多信息请参见此处,有关可用配置选项的更多信息请参见此处

现在我们可以更新 /auth/login 路径来返回 JWT

app.controller.ts

  1. import { Controller, Request, Post, UseGuards } from '@nestjs/common';
  2. import { AuthGuard } from '@nestjs/passport';
  3. import { AuthService } from './auth/auth.service';
  4. @Controller()
  5. export class AppController {
  6. constructor(private readonly authService: AuthService) {}
  7. @UseGuards(AuthGuard('local'))
  8. @Post('auth/login')
  9. async login(@Request() req) {
  10. return this.authService.login(req.user);
  11. }
  12. }

让我们继续使用 cURL 测试我们的路由。您可以使用 UsersService 中硬编码的任何用户对象进行测试。

  1. $ # POST to /auth/login
  2. $ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
  3. $ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
  4. $ # Note: above JWT truncated

实施 Passport JWT

我们现在可以处理我们的最终需求:通过要求在请求时提供有效的 JWT 来保护端点。护照对我们也有帮助。它提供了用于用 JSON Web 标记保护 RESTful 端点的 passport-jwt 策略。在 auth 文件夹中 jwt.strategy.ts,并添加以下代码:

auth/jwt.strategy.ts

  1. import { ExtractJwt, Strategy } from 'passport-jwt';
  2. import { PassportStrategy } from '@nestjs/passport';
  3. import { Injectable } from '@nestjs/common';
  4. import { jwtConstants } from './constants';
  5. @Injectable()
  6. export class JwtStrategy extends PassportStrategy(Strategy) {
  7. constructor() {
  8. super({
  9. jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  10. ignoreExpiration: false,
  11. secretOrKey: jwtConstants.secret,
  12. });
  13. }
  14. async validate(payload: any) {
  15. return { userId: payload.sub, username: payload.username };
  16. }
  17. }

对于我们的 JwtStrategy ,我们遵循了前面描述的所有 Passport 策略的相同配方。这个策略需要一些初始化,因此我们通过在 super() 调用中传递一个 options 对象来实现。您可以在这里阅读关于可用选项的更多信息。在我们的例子中,这些选项是:

  • jwtFromRequest:提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供token的标准方法。这里描述了其他选项。

ignoreExpiration:为了明确起见,我们选择默认的 false 设置,它将确保 JWT 没有过期的责任委托给 Passport 模块。这意味着,如果我们的路由提供了一个过期的 JWT ,请求将被拒绝,并发送 401 未经授权的响应。护照会自动为我们办理。

secret orkey:我们使用权宜的选项来提供对称的秘密来签署令牌。其他选项,如 pemo 编码的公钥,可能更适合于生产应用程序(有关更多信息,请参见此处)。如前所述,无论如何,不要把这个秘密公开。

validate() 方法值得讨论一下。对于 JWT 策略,Passport 首先验证 JWT 的签名并解码 JSON。然后调用我们的 validate() 方法,该方法将解码后的 JSON 作为其单个参数传递。根据 JWT 签名的工作方式,我们可以保证接收到之前已签名并发给有效用户的有效 token 令牌。

因此,我们对 validate() 回调的响应很简单:我们只是返回一个包含 userIdusername 属性的对象。再次回忆一下,Passport 将基于 validate() 方法的返回值构建一个user 对象,并将其作为属性附加到请求对象上。

同样值得指出的是,这种方法为我们留出了将其他业务逻辑注入流程的空间(就像”挂钩”一样)。例如,我们可以在 validate() 方法中执行数据库查询,以提取关于用户的更多信息,从而在请求中提供更丰富的用户对象。这也是我们决定进行进一步令牌验证的地方,例如在已撤销的令牌列表中查找 userId ,使我们能够执行令牌撤销。我们在示例代码中实现的模型是一个快速的 "无状态JWT" 模型,其中根据有效 JWT 的存在立即对每个 API 调用进行授权,并在请求管道中提供关于请求者(其 useridusername)的少量信息。

AuthModule 中添加新的 JwtStrategy 作为提供者:

auth/auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { LocalStrategy } from './local.strategy';
  4. import { JwtStrategy } from './jwt.strategy';
  5. import { UsersModule } from '../users/users.module';
  6. import { PassportModule } from '@nestjs/passport';
  7. import { JwtModule } from '@nestjs/jwt';
  8. import { jwtConstants } from './constants';
  9. @Module({
  10. imports: [
  11. UsersModule,
  12. PassportModule,
  13. JwtModule.register({
  14. secret: jwtConstants.secret,
  15. signOptions: { expiresIn: '60s' },
  16. }),
  17. ],
  18. providers: [AuthService, LocalStrategy, JwtStrategy],
  19. exports: [AuthService],
  20. })
  21. export class AuthModule {}

通过导入 JWT 签名时使用的相同密钥,我们可以确保 Passport 执行的验证阶段和 AuthService 执行的签名阶段使用公共密钥。

实现受保护的路由和 JWT 策略保护,我们现在可以实现受保护的路由及其相关的保护。

打开 app.controller.ts 文件,更新如下:

app.controller.ts

  1. import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
  2. import { AuthGuard } from '@nestjs/passport';
  3. import { AuthService } from './auth/auth.service';
  4. @Controller()
  5. export class AppController {
  6. constructor(private readonly authService: AuthService) {}
  7. @UseGuards(AuthGuard('local'))
  8. @Post('auth/login')
  9. async login(@Request() req) {
  10. return this.authService.login(req.user);
  11. }
  12. @UseGuards(AuthGuard('jwt'))
  13. @Get('profile')
  14. getProfile(@Request() req) {
  15. return req.user;
  16. }
  17. }

同样,我们将应用在配置 passport-jwt 模块时 @nestjs/passport 模块自动为我们提供的 AuthGuard 。这个保护由它的默认名称 jwt 引用。当我们请求GET /profile 路由时,保护程序将自动调用我们的 passport-jwt 自定义配置逻辑,验证 JWT ,并将用户属性分配给请求对象。

确保应用程序正在运行,并使用 cURL 测试路由。

  1. $ # GET /profile
  2. $ curl http://localhost:3000/profile
  3. $ # result -> {"statusCode":401,"error":"Unauthorized"}
  4. $ # POST /auth/login
  5. $ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
  6. $ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }
  7. $ # GET /profile using access_token returned from previous step as bearer code
  8. $ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
  9. $ # result -> {"userId":1,"username":"john"}

注意,在 AuthModule 中,我们将 JWT 配置为 60 秒过期。这个过期时间可能太短了,而处理令牌过期和刷新的细节超出了本文的范围。然而,我们选择它来展示JWT 的一个重要品质和 jwt 护照战略。如果您在验证之后等待 60 秒再尝试 GET /profile 请求,您将收到 401 未授权响应。这是因为 Passport 会自动检查 JWT 的过期时间,从而省去了在应用程序中这样做的麻烦。

我们现在已经完成了 JWT 身份验证实现。JavaScript 客户端(如 Angular/React/Vue )和其他 JavaScript 应用程序现在可以安全地与我们的 API 服务器进行身份验证和通信。在这里可以看到本节完整的程序代码。

默认策略

在我们的 AppController 中,我们在 @AuthGuard() 装饰器中传递策略的名称。我们需要这样做,因为我们已经介绍了两种 Passport 策略(护照本地策略和护照 jwt 策略),这两种策略都提供了各种 Passport 组件的实现。传递名称可以消除我们链接到的实现的歧义。当应用程序中包含多个策略时,我们可以声明一个默认策略,这样如果使用该默认策略,我们就不必在 @AuthGuard 装饰器中传递名称。下面介绍如何在导入 PassportModule 时注册默认策略。这段代码将进入 AuthModule :

要确定默认策略行为,您可以注册 PassportModule

auth.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { AuthService } from './auth.service';
  3. import { LocalStrategy } from './local.strategy';
  4. import { UsersModule } from '../users/users.module';
  5. import { PassportModule } from '@nestjs/passport';
  6. import { JwtModule } from '@nestjs/jwt';
  7. import { jwtConstants } from './constants';
  8. import { JwtStrategy } from './jwt.strategy';
  9. @Module({
  10. imports: [
  11. PassportModule.register({ defaultStrategy: 'jwt' }),
  12. JwtModule.register({
  13. secret: jwtConstants.secret,
  14. signOptions: { expiresIn: '60s' },
  15. }),
  16. UsersModule
  17. ],
  18. providers: [AuthService, LocalStrategy, JwtStrategy],
  19. exports: [AuthService],
  20. })
  21. export class AuthModule {}

请求范围策略

passportAPI基于将策略注册到库的全局实例。因此策略并没有设计为依赖请求的选项的或者根据每个请求动态生成实例(更多内容见请求范围提供者)。当你配置你的策略为请求范围时,Nest永远不会将其实例化,因为它并没有和任何特定路径绑定。并没有一个物理方法来决定哪个”请求范围”策略会根据每个请求执行。

然而,在策略中总有办法动态处理请求范围提供者。我们在这里利用模块参考特性。

首先,打开local.strategy.ts文件并且将ModuleRef按照正常方法注入其中:

  1. constructor(private moduleRef: ModuleRef){
  2. super({
  3. passReqToCallback:true;
  4. })
  5. }

!> 注意: ModuleRef 类需要从@nestjs/core中导入。

要保证passReqToCallback属性和上述示例中一样配置为true

在下一步中,请求的实例将被用于获取一个当前上下文标识,而不是生成一个新的(更多关于请求上下文的内容见这里)。

现在,在LocalStrategy类的validate()方法中,使用ContextIdFactory类中的getByRequest()方法来创建一个基于请求对向的上下文id,并将其传递给resolve()调用:

  1. async validate(
  2. request: Request,
  3. username: string,
  4. password: string,
  5. ) {
  6. const contextId = ContextIdFactory.getByRequest(request);
  7. // "AuthService" is a request-scoped provider
  8. const authService = await this.moduleRef.resolve(AuthService, contextId);
  9. ...
  10. }

在上述例子中,resolve()方法会异步返回AuthService提供者的请求范围实例(我们假设AuthService被标示为一个请求范围提供者)。

扩展守卫

在大多数情况下,使用一个提供的AuthGuard类是有用的。然而,在一些用例中你可能只是希望简单地扩展默认的错误处理或者认证逻辑。在这种情况下,你可以通过一个子类来扩展内置的类并且覆盖其方法。

  1. import {
  2. ExecutionContext,
  3. Injectable,
  4. UnauthorizedException,
  5. } from '@nestjs/common';
  6. import { AuthGuard } from '@nestjs/passport';
  7. @Injectable()
  8. export class JwtAuthGuard extends AuthGuard('jwt') {
  9. canActivate(context: ExecutionContext) {
  10. // 在这里添加自定义的认证逻辑
  11. // 例如调用 super.logIn(request) 来建立一个session
  12. return super.canActivate(context);
  13. }
  14. handleRequest(err, user, info) {
  15. // 可以抛出一个基于info或者err参数的异常
  16. if (err || !user) {
  17. throw err || new UnauthorizedException();
  18. }
  19. return user;
  20. }
  21. }

自定义 Passport

根据所使用的策略,Passport会采用一系列影响库行为的属性。使用 register() 方法将选项对象直接传递给Passport实例。例如:

  1. PassportModule.register({ session: true });

您还可以在策略的构造函数中传递一个 options 对象来配置它们。至于本地策略,你可以通过例如:

  1. constructor(private readonly authService: AuthService) {
  2. super({
  3. usernameField: 'email',
  4. passwordField: 'password',
  5. });
  6. }

看看Passport Website官方文档吧。

命名策略

在实现策略时,可以通过向 PassportStrategy 函数传递第二个参数来为其提供名称。如果你不这样做,每个策略将有一个默认的名称(例如,”jwt”的 jwt策略 ):

  1. export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

然后,通过一个像 @AuthGuard('myjwt') 这样的装饰器来引用它。

GraphQL

为了使用带有 GraphQLAuthGuard ,扩展内置的 AuthGuard 类并覆盖 getRequest() 方法。

  1. @Injectable()
  2. export class GqlAuthGuard extends AuthGuard('jwt') {
  3. getRequest(context: ExecutionContext) {
  4. const ctx = GqlExecutionContext.create(context);
  5. return ctx.getContext().req;
  6. }
  7. }

要使用上述结构,请确保在 GraphQL 模块设置中将 request (req)对象作为上下文值的一部分传递:

  1. GraphQLModule.forRoot({
  2. context: ({ req }) => ({ req }),
  3. });

要在 graphql 解析器中获得当前经过身份验证的用户,可以定义一个@CurrentUser()装饰器:

  1. import { createParamDecorator, ExecutionContext } from '@nestjs/common';
  2. import { GqlExecutionContext } from '@nestjs/graphql';
  3. export const CurrentUser = createParamDecorator(
  4. (data: unknown, context: ExecutionContext) => {
  5. const ctx = GqlExecutionContext.create(context);
  6. return ctx.getContext().req.user;
  7. },
  8. );

要在解析器中使用上述装饰器,请确保将其作为查询的参数:

  1. @Query(returns => User)
  2. @UseGuards(GqlAuthGuard)
  3. whoAmI(@CurrentUser() user: User) {
  4. return this.userService.findById(user.id);
  5. }

数据库

Nest 与数据库无关,允许您轻松地与任何 SQLNoSQL 数据库集成。根据您的偏好,您有许多可用的选项。一般来说,将 Nest 连接到数据库只需为数据库加载一个适当的 Node.js 驱动程序,就像使用 ExpressFastify 一样。

您还可以直接使用任何通用的 Node.js 数据库集成库或 ORM ,例如 Sequelize (recipe)knexjs (tutorial)`和 TypeORM ,以在更高的抽象级别上进行操作。

为了方便起见,Nest 还提供了与现成的 TypeORM@nestjs/typeorm 的紧密集成,我们将在本章中对此进行介绍,而与 @nestjs/mongoose 的紧密集成将在这一章中介绍。这些集成提供了附加的特定于 nestjs 的特性,比如模型/存储库注入、可测试性和异步配置,从而使访问您选择的数据库更加容易。

TypeORM 集成

为了与 SQLNoSQL 数据库集成,Nest 提供了 @nestjs/typeorm 包。Nest 使用TypeORM是因为它是 TypeScript 中最成熟的对象关系映射器( ORM )。因为它是用 TypeScript 编写的,所以可以很好地与 Nest 框架集成。

为了开始使用它,我们首先安装所需的依赖项。在本章中,我们将演示如何使用流行的 MysqlTypeORM 提供了对许多关系数据库的支持,比如 PostgreSQLOracleMicrosoft SQL ServerSQLite,甚至像 MongoDB这样的 NoSQL 数据库。我们在本章中介绍的过程对于 TypeORM 支持的任何数据库都是相同的。您只需为所选数据库安装相关的客户端 API 库。

  1. $ npm install --save @nestjs/typeorm typeorm mysql

安装过程完成后,我们可以将 TypeOrmModule 导入AppModule

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. @Module({
  4. imports: [
  5. TypeOrmModule.forRoot({
  6. type: 'mysql',
  7. host: 'localhost',
  8. port: 3306,
  9. username: 'root',
  10. password: 'root',
  11. database: 'test',
  12. entities: [],
  13. synchronize: true,
  14. }),
  15. ],
  16. })
  17. export class AppModule {}

forRoot() 方法支持所有TypeORM包中createConnection()函数暴露出的配置属性。其他一些额外的配置参数描述如下:

参数 说明
retryAttempts 重试连接数据库的次数(默认:10)
retryDelay 两次重试连接的间隔(ms)(默认:3000)
autoLoadEntities 如果为true,将自动加载实体(默认:false)
keepConnectionAlive 如果未true,在应用程序关闭后连接不会关闭(默认:false)

?> 更多连接选项见这里

另外,我们可以创建 ormconfig.json ,而不是将配置对象传递给 forRoot()

  1. {
  2. "type": "mysql",
  3. "host": "localhost",
  4. "port": 3306,
  5. "username": "root",
  6. "password": "root",
  7. "database": "test",
  8. "entities": ["dist/**/*.entity{.ts,.js}"],
  9. "synchronize": true
  10. }

然后,我们可以不带任何选项地调用 forRoot() :

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. @Module({
  4. imports: [TypeOrmModule.forRoot()],
  5. })
  6. export class AppModule {}

?> 静态全局路径(例如 dist/**/*.entity{ .ts,.js} )不适用于Webpack热重载。

!> 注意,ormconfig.json 文件由typeorm库载入,因此,任何上述参数之外的属性都不会被应用(例如由forRoot()方法内部支持的属性—例如autoLoadEntitiesretryDelay())

一旦完成,TypeORMConnectionEntityManager 对象就可以在整个项目中注入(不需要导入任何模块),例如:

app.module.ts

  1. import { Connection } from 'typeorm';
  2. @Module({
  3. imports: [TypeOrmModule.forRoot(), PhotoModule],
  4. })
  5. export class AppModule {
  6. constructor(private readonly connection: Connection) {}
  7. }

存储库模式

TypeORM 支持存储库设计模式,因此每个实体都有自己的存储库。可以从数据库连接获得这些存储库。

为了继续这个示例,我们需要至少一个实体。我们来定义User 实体。

user.entity.ts

  1. import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
  2. @Entity()
  3. export class User {
  4. @PrimaryGeneratedColumn()
  5. id: number;
  6. @Column()
  7. firstName: string;
  8. @Column()
  9. lastName: string;
  10. @Column({ default: true })
  11. isActive: boolean;
  12. }

?> 关于实体的更多内容见TypeORM 文档

User 实体在 users 目录下。这个目录包含了和 UsersModule模块有关的所有文件。你可以决定在哪里保存模型文件,但我们推荐在他们的中就近创建,即在相应的模块目录中。

要开始使用 user 实体,我们需要在模块的forRoot()方法的选项中(除非你使用一个静态的全局路径)将它插入entities数组中来让 TypeORM知道它的存在。

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. import { Photo } from './photo/photo.entity';
  4. @Module({
  5. imports: [
  6. TypeOrmModule.forRoot({
  7. type: 'mysql',
  8. host: 'localhost',
  9. port: 3306,
  10. username: 'root',
  11. password: 'root',
  12. database: 'test',
  13. entities: [User],
  14. synchronize: true,
  15. }),
  16. ],
  17. })
  18. export class AppModule {}

现在让我们看一下 UsersModule

user.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. import { UsersService } from './users.service';
  4. import { UsersController } from './users.controller';
  5. import { User } from './user.entity';
  6. @Module({
  7. imports: [TypeOrmModule.forFeature([User])],
  8. providers: [UsersService],
  9. controllers: [UsersController],
  10. })
  11. export class UsersModule {}

此模块使用 forFeature() 方法定义在当前范围中注册哪些存储库。这样,我们就可以使用 @InjectRepository()装饰器将 UsersRepository 注入到 UsersService 中:

users.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { InjectRepository } from '@nestjs/typeorm';
  3. import { Repository } from 'typeorm';
  4. import { User } from './user.entity';
  5. @Injectable()
  6. export class UsersService {
  7. constructor(
  8. @InjectRepository(User)
  9. private usersRepository: Repository<User>,
  10. ) {}
  11. findAll(): Promise<User[]> {
  12. return this.usersRepository.find();
  13. }
  14. findOne(id: string): Promise<User> {
  15. return this.usersRepository.findOne(id);
  16. }
  17. async remove(id: string): Promise<void> {
  18. await this.usersRepository.delete(id);
  19. }
  20. }

?> 不要忘记将 UsersModule 导入根 AppModule

如果要在导入TypeOrmModule.forFeature 的模块之外使用存储库,则需要重新导出由其生成的提供程序。 您可以通过导出整个模块来做到这一点,如下所示:

users.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. import { User } from './user.entity';
  4. @Module({
  5. imports: [TypeOrmModule.forFeature([User])],
  6. exports: [TypeOrmModule]
  7. })
  8. export class UsersModule {}

现在,如果我们在 UserHttpModule 中导入 UsersModule ,我们可以在后一个模块的提供者中使用 @InjectRepository(User)

users-http.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { UsersModule } from './user.module';
  3. import { UsersService } from './users.service';
  4. import { UsersController } from './users.controller';
  5. @Module({
  6. imports: [UsersModule],
  7. providers: [UsersService],
  8. controllers: [UsersController]
  9. })
  10. export class UserHttpModule {}

关系

关系是指两个或多个表之间的联系。关系基于每个表中的常规字段,通常包含主键和外键。

关系有三种:

名称 说明
一对一 主表中的每一行在外部表中有且仅有一个对应行。使用@OneToOne()装饰器来定义这种类型的关系
一对多/多对一 主表中的每一行在外部表中有一个或多的对应行。使用@OneToMany()@ManyToOne()装饰器来定义这种类型的关系
多对多 主表中的每一行在外部表中有多个对应行,外部表中的每个记录在主表中也有多个行。使用@ManyToMany()装饰器来定义这种类型的关系

使用对应的装饰器来定义实体的关系。例如,要定义每个User可以有多个Photo,可以使用@OneToMany()装饰器。

user.entity.ts

  1. import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
  2. import { Photo } from '../photos/photo.entity';
  3. @Entity()
  4. export class User {
  5. @PrimaryGeneratedColumn()
  6. id: number;
  7. @Column()
  8. firstName: string;
  9. @Column()
  10. lastName: string;
  11. @Column({ default: true })
  12. isActive: boolean;
  13. @OneToMany(type => Photo, photo => photo.user)
  14. photos: Photo[];
  15. }

?> 要了解TypeORM中关系的内容,可以查看TypeORM文档

自动载入实体

手动将实体一一添加到连接选项的entities数组中的工作会很无聊。此外,在根模块中涉及实体破坏了应用的域边界,并可能将应用的细节泄露给应用的其他部分。针对这一情况,可以使用静态全局路径(例如, dist/*/.entity{.ts,.js})。

注意,webpack不支持全局路径,因此如果你要在单一仓库(Monorepo)中构建应用,可能不能使用全局路径。针对这一问题,有另外一个可选的方案。在配置对象的属性中(传递给forRoot()方法的)设置autoLoadEntities属性为true来自动载入实体,示意如下:

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. @Module({
  4. imports: [
  5. TypeOrmModule.forRoot({
  6. ...
  7. autoLoadEntities: true,
  8. }),
  9. ],
  10. })
  11. export class AppModule {}

通过配置这一选项,每个通过forFeature()注册的实体都会自动添加到配置对象的entities数组中。

?> 注意,那些没有通过forFeature()方法注册,而仅仅是在实体中被引用(通过关系)的实体不能通过autoLoadEntities配置被包含。

事务

数据库事务代表在数据库管理系统(DBMS)中针对数据库的一组操作,这组操作是有关的、可靠的并且和其他事务相互独立的。一个事务通常可以代表数据库中的任何变更(了解更多)。

TypeORM事务中有很多不同策略来处理事务,我们推荐使用QueryRunner类,因为它对事务是完全可控的。

首先,我们需要将Connection对象以正常方式注入:

  1. @Injectable()
  2. export class UsersService {
  3. constructor(private connection: Connection) {}
  4. }

?> Connection类需要从typeorm包中导入

现在,我们可以使用这个对象来创建一个事务。

  1. async createMany(users: User[]) {
  2. const queryRunner = this.connection.createQueryRunner();
  3. await queryRunner.connect();
  4. await queryRunner.startTransaction();
  5. try {
  6. await queryRunner.manager.save(users[0]);
  7. await queryRunner.manager.save(users[1]);
  8. await queryRunner.commitTransaction();
  9. } catch (err) {
  10. //如果遇到错误,可以回滚事务
  11. await queryRunner.rollbackTransaction();
  12. } finally {
  13. //你需要手动实例化并部署一个queryRunner
  14. await queryRunner.release();
  15. }
  16. }

?> 注意connection仅用于创建QueryRunner。然而,要测试这个类,就需要模拟整个Connection对象(它暴露出来的几个方法),因此,我们推荐采用一个帮助工厂类(也就是QueryRunnerFactory)并且定义一个包含仅限于维持事务需要的方法的接口。这一技术让模拟这些方法变得非常直接。

可选地,你可以使用一个Connection对象的回调函数风格的transaction方法(阅读更多)。

  1. async createMany(users: User[]) {
  2. await this.connection.transaction(async manager => {
  3. await manager.save(users[0]);
  4. await manager.save(users[1]);
  5. });
  6. }

不推荐使用装饰器来控制事务(@Transaction()@TransactionManager())。

订阅者

使用TypeORM订阅者,你可以监听特定的实体事件。

  1. import {
  2. Connection,
  3. EntitySubscriberInterface,
  4. EventSubscriber,
  5. InsertEvent,
  6. } from 'typeorm';
  7. import { User } from './user.entity';
  8. @EventSubscriber()
  9. export class UserSubscriber implements EntitySubscriberInterface<User> {
  10. constructor(connection: Connection) {
  11. connection.subscribers.push(this);
  12. }
  13. listenTo() {
  14. return User;
  15. }
  16. beforeInsert(event: InsertEvent<User>) {
  17. console.log(`BEFORE USER INSERTED: `, event.entity);
  18. }
  19. }

!> 事件订阅者不能是请求范围的。

现在,将UserSubscriber类添加到providers数组。

  1. import { Module } from '@nestjs/common';
  2. import { TypeOrmModule } from '@nestjs/typeorm';
  3. import { User } from './user.entity';
  4. import { UsersController } from './users.controller';
  5. import { UsersService } from './users.service';
  6. import { UserSubscriber } from './user.subscriber';
  7. @Module({
  8. imports: [TypeOrmModule.forFeature([User])],
  9. providers: [UsersService, UserSubscriber],
  10. controllers: [UsersController],
  11. })
  12. export class UsersModule {}

?> 更多实体订阅者内容见这里

迁移

迁移提供了一个在保存数据库中现有数据的同时增量升级数据库使其与应用中的数据模型保持同步的方法。TypeORM提供了一个专用CLI命令行工具用于生成、运行以及回滚迁移。

迁移类和Nest应用源码是分开的。他们的生命周期由TypeORM CLI管理,因此,你不能在迁移中使用依赖注入和其他Nest专有特性。在TypeORM文档 中查看更多关于迁移的内容。

多个数据库

某些项目可能需要多个数据库连接。这也可以通过本模块实现。要使用多个连接,首先要做的是创建这些连接。在这种情况下,连接命名成为必填项。

假设你有一个Album 实体存储在他们自己的数据库中。

  1. const defaultOptions = {
  2. type: 'postgres',
  3. port: 5432,
  4. username: 'user',
  5. password: 'password',
  6. database: 'db',
  7. synchronize: true,
  8. };
  9. @Module({
  10. imports: [
  11. TypeOrmModule.forRoot({
  12. ...defaultOptions,
  13. host: 'user_db_host',
  14. entities: [User],
  15. }),
  16. TypeOrmModule.forRoot({
  17. ...defaultOptions,
  18. name: 'albumsConnection',
  19. host: 'album_db_host',
  20. entities: [Album],
  21. }),
  22. ],
  23. })
  24. export class AppModule {}

?> 如果未为连接设置任何 name ,则该连接的名称将设置为 default。请注意,不应该有多个没有名称或同名的连接,否则它们会被覆盖。

此时,您的UserAlbum 实体中的每一个都已在各自的连接中注册。通过此设置,您必须告诉 TypeOrmModule.forFeature() 方法和 @InjectRepository() 装饰器应该使用哪种连接。如果不传递任何连接名称,则使用 default 连接。

  1. @Module({
  2. imports: [
  3. TypeOrmModule.forFeature([User]),
  4. TypeOrmModule.forFeature([Album], 'albumsConnection'),
  5. ],
  6. })
  7. export class AppModule {}

您也可以为给定的连接注入 ConnectionEntityManager

  1. @Injectable()
  2. export class AlbumsService {
  3. constructor(
  4. @InjectConnection('albumsConnection')
  5. private connection: Connection,
  6. @InjectEntityManager('albumsConnection')
  7. private entityManager: EntityManager,
  8. ) {}
  9. }

测试

在单元测试我们的应用程序时,我们通常希望避免任何数据库连接,从而使我们的测试适合于独立,并使它们的执行过程尽可能快。但是我们的类可能依赖于从连接实例中提取的存储库。那是什么?解决方案是创建假存储库。为了实现这一点,我们设置了[自定义提供者]。事实上,每个注册的存储库都由 entitynamereposition 标记表示,其中 EntityName 是实体类的名称。

@nestjs/typeorm 包提供了基于给定实体返回准备好 tokengetRepositoryToken() 函数。

  1. @Module({
  2. providers: [
  3. UsersService,
  4. {
  5. provide: getRepositoryToken(User),
  6. useValue: mockRepository,
  7. },
  8. ],
  9. })
  10. export class UsersModule {}

现在, 将使用mockRepository 作为 UsersRepository。每当任何提供程序使用 @InjectRepository() 装饰器请求 UsersRepository 时, Nest 会使用注册的 mockRepository 对象。

定制存储库

TypeORM 提供称为自定义存储库的功能。要了解有关它的更多信息,请访问此页面。基本上,自定义存储库允许您扩展基本存储库类,并使用几种特殊方法对其进行丰富。

要创建自定义存储库,请使用 @EntityRepository() 装饰器和扩展 Repository 类。

  1. @EntityRepository(Author)
  2. export class AuthorRepository extends Repository<Author> {}

?> @EntityRepository()Repository 来自 typeorm 包。

创建类后,下一步是将实例化责任移交给 Nest。为此,我们必须将 AuthorRepository 类传递给 TypeOrm.forFeature() 函数。

  1. @Module({
  2. imports: [TypeOrmModule.forFeature([AuthorRepository])],
  3. controller: [AuthorController],
  4. providers: [AuthorService],
  5. })
  6. export class AuthorModule {}

之后,只需使用以下构造注入存储库:

  1. @Injectable()
  2. export class AuthorService {
  3. constructor(private readonly authorRepository: AuthorRepository) {}
  4. }

异步配置

通常,您可能希望异步传递模块选项,而不是事先传递它们。在这种情况下,使用 forRootAsync() 函数,提供了几种处理异步数据的方法。

第一种可能的方法是使用工厂函数:

  1. TypeOrmModule.forRootAsync({
  2. useFactory: () => ({
  3. type: 'mysql',
  4. host: 'localhost',
  5. port: 3306,
  6. username: 'root',
  7. password: 'root',
  8. database: 'test',
  9. entities: [__dirname + '/**/*.entity{.ts,.js}'],
  10. synchronize: true,
  11. }),
  12. });

我们的工厂的行为与任何其他异步提供者一样(例如,它可以是异步的,并且它能够通过inject注入依赖)。

  1. TypeOrmModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useFactory: (configService: ConfigService) => ({
  4. type: 'mysql',
  5. host: configService.get<string>('HOST'),
  6. port: configService.get<string>('PORT'),
  7. username: configService.get<string>('USERNAME'),
  8. password: configService.get<string>('PASSWORD'),
  9. database: configService.get<string>('DATABASE'),
  10. entities: [__dirname + '/**/*.entity{.ts,.js}'],
  11. synchronize: true,
  12. }),
  13. inject: [ConfigService],
  14. });

或者,您可以使用useClass语法。

  1. TypeOrmModule.forRootAsync({
  2. useClass: TypeOrmConfigService,
  3. });

上面的构造将 TypeOrmConfigService 在内部进行实例化 TypeOrmModule,并将利用它来创建选项对象。在 TypeOrmConfigService 必须实现 TypeOrmOptionsFactory 的接口。

上面的构造将在TypeOrmModule内部实例化TypeOrmConfigService,并通过调用createTypeOrmOptions()

  1. @Injectable()
  2. class TypeOrmConfigService implements TypeOrmOptionsFactory {
  3. createTypeOrmOptions(): TypeOrmModuleOptions {
  4. return {
  5. type: 'mysql',
  6. host: 'localhost',
  7. port: 3306,
  8. username: 'root',
  9. password: 'root',
  10. database: 'test',
  11. entities: [__dirname + '/**/*.entity{.ts,.js}'],
  12. synchronize: true,
  13. };
  14. }
  15. }

为了防止在 TypeOrmModule 中创建 TypeOrmConfigService 并使用从不同模块导入的提供程序,可以使用 useExisting 语法。

  1. TypeOrmModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

这个构造与 useClass 的工作原理相同,但有一个关键的区别 — TypeOrmModule 将查找导入的模块来重用现有的 ConfigService,而不是实例化一个新的 ConfigService

示例

这儿有一个可用的例子。

序列化集成

另一个使用TypeORM的选择是使用@nestjs/sequelize包中的Sequelize ROM。额外地,我们使用sequelize-typescript包来提供一系列额外的装饰器以声明实体。

要开始使用它,我们首先安装需要的依赖。在本章中,我们通过流行的MySQL关系数据库来进行说明。序列化支持很多种关系数据库,例如PostgreSQL,MySQL,Microsoft SQL Server,SQLite以及MariaDB。本章中的步骤也适合其他任何序列化支持的数据库。你只要简单地安装所选数据库相应的客户端API库就可以。

  1. $ npm install --save @nestjs/sequelize sequelize sequelize-typescript mysql2
  2. $ npm install --save-dev @types/sequelize

安装完成后,就可以将SequelizeModule导入到根AppModule中。

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { SequelizeModule } from '@nestjs/sequelize';
  3. @Module({
  4. imports: [
  5. SequelizeModule.forRoot({
  6. dialect: 'mysql',
  7. host: 'localhost',
  8. port: 3306,
  9. username: 'root',
  10. password: 'root',
  11. database: 'test',
  12. models: [],
  13. }),
  14. ],
  15. })
  16. export class AppModule {}

forRoot()方法支持所有序列化构造器(了解更多)暴露的配置属性。下面是一些额外的配置属性。

名称 说明
retryAttempts 尝试连接数据库的次数(默认:10)
retryDelay 两次连接之间间隔时间(ms)(默认:3000)
autoLoadModels 如果为true,模型将自动载入(默认:false)
keepConnectionAlive 如果为true,在应用关闭后连接将不会关闭(默认:false)
synchronize 如果为true,自动载入的模型将同步(默认:false)

一旦这些完成了,Sequelize对象就可以注入到整个项目中(不需要在任何模块中再引入),例如:

app.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { Sequelize } from 'sequelize-typescript';
  3. @Injectable()
  4. export class AppService {
  5. constructor(private sequelize: Sequelize) {}
  6. }

模型

序列化采用活动记录(Active Record)模式,在这一模式下,你可以使用模型类直接和数据库交互。要继续该示例,我们至少需要一个模型,让我们定义这个User模型:

user.model.ts

  1. import { Column, Model, Table } from 'sequelize-typescript';
  2. @Table
  3. export class User extends Model<User> {
  4. @Column
  5. firstName: string;
  6. @Column
  7. lastName: string;
  8. @Column({ defaultValue: true })
  9. isActive: boolean;
  10. }

?> 查看更多的可用装饰器。

User模型文件在users目录下。该目录包含了和UsersModule有关的所有文件。你可以决定在哪里保存模型文件,但我们推荐在他们的中就近创建,即在相应的模块目录中。

要开始使用User模型,我们需要通过将其插入到forRoot()方法选项的models数组中来让序列化知道它的存在。

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { SequelizeModule } from '@nestjs/sequelize';
  3. import { User } from './users/user.model';
  4. @Module({
  5. imports: [
  6. SequelizeModule.forRoot({
  7. dialect: 'mysql',
  8. host: 'localhost',
  9. port: 3306,
  10. username: 'root',
  11. password: 'root',
  12. database: 'test',
  13. models: [User],
  14. }),
  15. ],
  16. })
  17. export class AppModule {}

接下来我们看看UsersModule

users.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { SequelizeModule } from '@nestjs/sequelize';
  3. import { User } from './user.model';
  4. import { UsersController } from './users.controller';
  5. import { UsersService } from './users.service';
  6. @Module({
  7. imports: [SequelizeModule.forFeature([User])],
  8. providers: [UsersService],
  9. controllers: [UsersController],
  10. })
  11. export class UsersModule {}

这个模块使用forFeature()方法来定义哪个模型被注册在当前范围中。我们可以使用@InjectModel()装饰器来把UserModel注入到UsersService中。

users.service.ts

  1. import { Injectable } from '@nestjs/common';
  2. import { InjectModel } from '@nestjs/sequelize';
  3. import { User } from './user.model';
  4. @Injectable()
  5. export class UsersService {
  6. constructor(
  7. @InjectModel(User)
  8. private userModel: typeof User,
  9. ) {}
  10. async findAll(): Promise<User[]> {
  11. return this.userModel.findAll();
  12. }
  13. findOne(id: string): Promise<User> {
  14. return this.userModel.findOne({
  15. where: {
  16. id,
  17. },
  18. });
  19. }
  20. async remove(id: string): Promise<void> {
  21. const user = await this.findOne(id);
  22. await user.destroy();
  23. }
  24. }

?> 不要忘记在根AppModule中导入UsersModule

如果你要在导入SequelizeModule.forFreature的模块之外使用存储库,你需要重新导出其生成的提供者。你可以像这样将整个模块导出:

users.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { SequelizeModule } from '@nestjs/sequelize';
  3. import { User } from './user.entity';
  4. @Module({
  5. imports: [SequelizeModule.forFeature([User])],
  6. exports: [SequelizeModule]
  7. })
  8. export class UsersModule {}

现在如果我们在UserHttpModule中引入UsersModule,我们可以在后一个模块的提供者中使用@InjectModel(User)

users-http.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { UsersModule } from './user.module';
  3. import { UsersService } from './users.service';
  4. import { UsersController } from './users.controller';
  5. @Module({
  6. imports: [UsersModule],
  7. providers: [UsersService],
  8. controllers: [UsersController]
  9. })
  10. export class UserHttpModule {}

关系

关系是指两个或多个表之间的联系。关系基于每个表中的常规字段,通常包含主键和外键。

关系有三种:

名称 说明
一对一 主表中的每一行在外部表中有且仅有一个对应行。使用@OneToOne()装饰器来定义这种类型的关系
一对多/多对一 主表中的每一行在外部表中有一个或多的对应行。使用@OneToMany()@ManyToOne()装饰器来定义这种类型的关系
多对多 主表中的每一行在外部表中有多个对应行,外部表中的每个记录在主表中也有多个行。使用@ManyToMany()装饰器来定义这种类型的关系

使用对应的装饰器来定义实体的关系。例如,要定义每个User可以有多个Photo,可以使用@HasMany()装饰器。

user.entity.ts

  1. import { Column, Model, Table, HasMany } from 'sequelize-typescript';
  2. import { Photo } from '../photos/photo.model';
  3. @Table
  4. export class User extends Model<User> {
  5. @Column
  6. firstName: string;
  7. @Column
  8. lastName: string;
  9. @Column({ defaultValue: true })
  10. isActive: boolean;
  11. @HasMany(() => Photo)
  12. photos: Photo[];
  13. }

?> 阅读本章了解更多关于序列化的内容。

自动载入模型

手动将模型一一添加到连接选项的models数组中的工作会很无聊。此外,在根模块中涉及实体破坏了应用的域边界,并可能将应用的细节泄露给应用的其他部分。针对这一情况,在配置对象的属性中(传递给forRoot()方法的)设置autoLoadModelssynchronize属性来自动载入模型,示意如下:

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { SequelizeModule } from '@nestjs/sequelize';
  3. @Module({
  4. imports: [
  5. SequelizeModule.forRoot({
  6. ...
  7. autoLoadModels: true,
  8. synchronize: true,
  9. }),
  10. ],
  11. })
  12. export class AppModule {}

通过配置这一选项,每个通过forFeature()注册的实体都会自动添加到配置对象的models数组中。

?> 注意,这不包含那些没有通过forFeature()方法注册,而仅仅是在实体中被引用(通过关系)的模型。

事务

数据库事务代表在数据库管理系统(DBMS)中针对数据库的一组操作,这组操作是有关的、可靠的并且和其他事务相互独立的。一个事务通常可以代表数据库中的任何变更(了解更多)。

序列化事务中有很多不同策略来处理事务,下面是一个管理事务的示例(自动回调)。

首先,我们需要将Sequelize对象以正常方式注入:

  1. @Injectable()
  2. export class UsersService {
  3. constructor(private sequelize: Sequelize) {}
  4. }

?> Sequelize类需要从sequelize-typescript包中导入

现在,我们可以使用这个对象来创建一个事务。

  1. async createMany() {
  2. try {
  3. await this.sequelize.transaction(async t => {
  4. const transactionHost = { transaction: t };
  5. await this.userModel.create(
  6. { firstName: 'Abraham', lastName: 'Lincoln' },
  7. transactionHost,
  8. );
  9. await this.userModel.create(
  10. { firstName: 'John', lastName: 'Boothe' },
  11. transactionHost,
  12. );
  13. });
  14. } catch (err) {
  15. // 一旦发生错误,事务会回滚
  16. }
  17. }

?> 注意Sequelize仅用于开始一个事务。然而,要测试这个类,就需要模拟整个Sequelize对象(它暴露出来的几个方法),因此,我们推荐采用一个帮助工厂类(也就是TransactionRunner)并且定义一个包含仅限于维持事务需要的方法的接口。这一技术让模拟这些方法变得非常直接。

可选地,你可以使用一个Connection对象的回调函数风格的transaction方法(阅读更多)。

  1. async createMany(users: User[]) {
  2. await this.connection.transaction(async manager => {
  3. await manager.save(users[0]);
  4. await manager.save(users[1]);
  5. });
  6. }

不推荐使用装饰器来控制事务(@Transaction()@TransactionManager())。

迁移

迁移提供了一个在保存数据库中现有数据的同时增量升级数据库使其与应用中的数据模型保持同步的方法。序列化提供了一个专用CLI命令行工具用于生成、运行以及回滚迁移。

迁移类和Nest应用源码是分开的。他们的生命周期由TypeORM CLI管理,因此,你不能在迁移中使用依赖注入和其他Nest专有特性。在序列化文档 中查看更多关于迁移的内容。

多个数据库

某些项目可能需要多个数据库连接。这也可以通过本模块实现。要使用多个连接,首先要做的是创建这些连接。在这种情况下,连接命名成为必填项。

假设你有一个Album 实体存储在他们自己的数据库中。

  1. const defaultOptions = {
  2. dialect: 'postgres',
  3. port: 5432,
  4. username: 'user',
  5. password: 'password',
  6. database: 'db',
  7. synchronize: true,
  8. };
  9. @Module({
  10. imports: [
  11. SequelizeModule.forRoot({
  12. ...defaultOptions,
  13. host: 'user_db_host',
  14. models: [User],
  15. }),
  16. SequelizeModule.forRoot({
  17. ...defaultOptions,
  18. name: 'albumsConnection',
  19. host: 'album_db_host',
  20. models: [Album],
  21. }),
  22. ],
  23. })
  24. export class AppModule {}

?> 如果未为连接设置任何 name ,则该连接的名称将设置为 default。请注意,不应该有多个没有名称或同名的连接,否则它们会被覆盖。

此时,您的UserAlbum 实体中的每一个都已在各自的连接中注册。通过此设置,您必须告诉 SequelizeModule.forFeature() 方法和 @InjectRepository() 装饰器应该使用哪种连接。如果不传递任何连接名称,则使用 default 连接。

  1. @Module({
  2. imports: [
  3. SequelizeModule.forFeature([User]),
  4. SequelizeModule.forFeature([Album], 'albumsConnection'),
  5. ],
  6. })
  7. export class AppModule {}

您也可以为给定的连接注入 Sequelize

  1. @Injectable()
  2. export class AlbumsService {
  3. constructor(
  4. @InjectConnection('albumsConnection')
  5. private sequelize: Sequelize,
  6. ) {}
  7. }

测试

在单元测试我们的应用程序时,我们通常希望避免任何数据库连接,从而使我们的测试适合于独立,并使它们的执行过程尽可能快。但是我们的类可能依赖于从连接实例中提取的存储库。那是什么?解决方案是创建假模型。为了实现这一点,我们设置了[自定义提供者]。事实上,每个注册的模型都由 <ModelName>Model 令牌自动表示,其中 ModelName 是模型类的名称。

@nestjs/sequelize 包提供了基于给定模型返回准备好 tokengetModelToken() 函数。

  1. @Module({
  2. providers: [
  3. UsersService,
  4. {
  5. provide: getModelToken(User),
  6. useValue: mockModel,
  7. },
  8. ],
  9. })
  10. export class UsersModule {}

现在, 将使用mockModel 作为 UsersModel。每当任何提供程序使用 @InjectModel() 装饰器请求 UserModel 时, Nest 会使用注册的 mockModel 对象。

异步配置

通常,您可能希望异步传递SequelizeModule选项,而不是事先静态传递它们。在这种情况下,使用 forRootAsync() 函数,提供了几种处理异步数据的方法。

第一种可能的方法是使用工厂函数:

  1. SequelizeModule.forRootAsync({
  2. useFactory: () => ({
  3. dialect: 'mysql',
  4. host: 'localhost',
  5. port: 3306,
  6. username: 'root',
  7. password: 'root',
  8. database: 'test',
  9. models: [],
  10. }),
  11. });

我们的工厂的行为与任何其他异步提供者一样(例如,它可以是异步的,并且它能够通过inject注入依赖)。

  1. SequelizeModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useFactory: (configService: ConfigService) => ({
  4. dialect: 'mysql',
  5. host: configService.get<string>('HOST'),
  6. port: configService.get<string>('PORT'),
  7. username: configService.get<string>('USERNAME'),
  8. password: configService.get<string>('PASSWORD'),
  9. database: configService.get<string>('DATABASE'),
  10. models: [],
  11. }),
  12. inject: [ConfigService],
  13. });

或者,您可以使用useClass语法。

  1. SequelizeModule.forRootAsync({
  2. useClass: SequelizeConfigService,
  3. });

上面的构造将 SequelizeConfigServiceSequelizeModule内部进行实例化 ,并通过调用createSequelizeOptions()来创建一个选项对象。注意,这意味着 SequelizeConfigService 必须实现 SequelizeOptionsFactory 的接口。如下所示:

  1. @Injectable()
  2. class SequelizeConfigService implements SequelizeOptionsFactory {
  3. createSequelizeOptions(): SequelizeModuleOptions {
  4. return {
  5. dialect: 'mysql',
  6. host: 'localhost',
  7. port: 3306,
  8. username: 'root',
  9. password: 'root',
  10. database: 'test',
  11. models: [],
  12. };
  13. }
  14. }

为了防止在 SequelizeModule 中创建 SequelizeConfigService 并使用从不同模块导入的提供程序,可以使用 useExisting 语法。

  1. SequelizeModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

这个构造与 useClass 的工作原理相同,但有一个关键的区别 — SequelizeModule 将查找导入的模块来重用现有的 ConfigService,而不是实例化一个新的 ConfigService

示例

这儿有一个可用的例子。

Mongo

Nest支持两种与 MongoDB 数据库集成的方式。既使用内置的TypeORM 提供的 MongoDB 连接器,或使用最流行的MongoDB对象建模工具 Mongoose。在本章后续描述中我们使用专用的@nestjs/mongoose包。

首先,我们需要安装所有必需的依赖项:

  1. $ npm install --save @nestjs/mongoose mongoose
  2. $ npm install --save-dev @types/mongoose

安装过程完成后,我们可以将其 MongooseModule 导入到根目录 AppModule 中。

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { MongooseModule } from '@nestjs/mongoose';
  3. @Module({
  4. imports: [MongooseModule.forRoot('mongodb://localhost/nest')],
  5. })
  6. export class AppModule {}

forRoot()mongoose 包中的 mongoose.connect() 一样的参数对象。参见

模型注入

Mongoose中,一切都源于[Scheme](http://mongoosejs.com/docs/guide.html),我们先定义CatSchema:

schemas/cat.schema.ts

  1. import * as mongoose from 'mongoose';
  2. export const CatSchema = new mongoose.Schema({
  3. name: String,
  4. age: Number,
  5. breed: String,
  6. });

cat.schema 文件在 cats 目录下。这个目录包含了和 CatsModule模块有关的所有文件。你可以决定在哪里保存Schema文件,但我们推荐在他们的中就近创建,即在相应的模块目录中。

我们来看看CatsModule

cats.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { MongooseModule } from '@nestjs/mongoose';
  3. import { CatsController } from './cats.controller';
  4. import { CatsService } from './cats.service';
  5. import { CatSchema } from './schemas/cat.schema';
  6. @Module({
  7. imports: [MongooseModule.forFeature([{ name: 'Cat', schema: CatSchema }])],
  8. controllers: [CatsController],
  9. providers: [CatsService],
  10. })
  11. export class CatsModule {}

MongooseModule提供了forFeature()方法来配置模块,包括定义哪些模型应该注册在当前范围中。如果你还想在另外的模块中使用这个模型,将MongooseModule添加到CatsModuleexports部分并在其他模块中导入CatsModule

注册Schema后,可以使用 @InjectModel() 装饰器将 Cat 模型注入到 CatsService 中:

cats.service.ts

  1. import { Model } from 'mongoose';
  2. import { Injectable } from '@nestjs/common';
  3. import { InjectModel } from '@nestjs/mongoose';
  4. import { Cat } from './interfaces/cat.interface';
  5. import { CreateCatDto } from './dto/create-cat.dto';
  6. @Injectable()
  7. export class CatsService {
  8. constructor(@InjectModel('Cat') private catModel: Model<Cat>) {}
  9. async create(createCatDto: CreateCatDto): Promise<Cat> {
  10. const createdCat = new this.catModel(createCatDto);
  11. return createdCat.save();
  12. }
  13. async findAll(): Promise<Cat[]> {
  14. return this.catModel.find().exec();
  15. }
  16. }

连接

有时你可能需要连接原生的Mongoose连接对象,你可能在连接对象中想使用某个原生的API。你可以使用如下的@InjectConnection()装饰器来注入Mongoose连接。

  1. import { Injectable } from '@nestjs/common';
  2. import { InjectConnection } from '@nestjs/mongoose';
  3. import { Connection } from 'mongoose';
  4. @Injectable()
  5. export class CatsService {
  6. constructor(@InjectConnection() private connection: Connection) {}
  7. }

多数据库

有的项目需要多数据库连接,可以在这个模块中实现。要使用多连接,首先要创建连接,在这种情况下,连接必须**要有名称。

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { MongooseModule } from '@nestjs/mongoose';
  3. @Module({
  4. imports: [
  5. MongooseModule.forRoot('mongodb://localhost/test', {
  6. connectionName: 'cats',
  7. }),
  8. MongooseModule.forRoot('mongodb://localhost/users', {
  9. connectionName: 'users',
  10. }),
  11. ],
  12. })
  13. export class AppModule {}

?> 你不能在没有名称的情况下使用多连接,也不能对多连接使用同一个名称,否则会被覆盖掉。

在设置中,要告诉MongooseModule.forFeature()方法应该使用哪个连接。

  1. @Module({
  2. imports: [
  3. MongooseModule.forFeature([{ name: 'Cat', schema: CatSchema }], 'cats'),
  4. ],
  5. })
  6. export class AppModule {}

也可以向一个给定的连接中注入Connection

  1. import { Injectable } from '@nestjs/common';
  2. import { InjectConnection } from '@nestjs/mongoose';
  3. import { Connection } from 'mongoose';
  4. @Injectable()
  5. export class CatsService {
  6. constructor(@InjectConnection('cats') private connection: Connection) {}
  7. }

钩子(中间件)

中间件(也被称作预处理(pre)和后处理(post)钩子)是在执行异步函数时传递控制的函数。中间件是针对Schema层级的,在写插件(源码)时非常有用。在Mongoose编译完模型后使用pre()post()不会起作用。要在模型注册前注册一个钩子,可以在使用一个工厂提供者(例如 useFactory)是使用MongooseModule中的forFeatureAsync()方法。使用这一技术,你可以访问一个Schema对象,然后使用pre()post()方法来在那个schema中注册一个钩子。示例如下:

  1. @Module({
  2. imports: [
  3. MongooseModule.forFeatureAsync([
  4. {
  5. name: 'Cat',
  6. useFactory: () => {
  7. const schema = CatsSchema;
  8. schema.pre('save', () => console.log('Hello from pre save'));
  9. return schema;
  10. },
  11. },
  12. ]),
  13. ],
  14. })
  15. export class AppModule {}

和其他工厂提供者一样,我们的工厂函数是异步的,可以通过inject注入依赖。

  1. @Module({
  2. imports: [
  3. MongooseModule.forFeatureAsync([
  4. {
  5. name: 'Cat',
  6. imports: [ConfigModule],
  7. useFactory: (configService: ConfigService) => {
  8. const schema = CatsSchema;
  9. schema.pre('save', () =>
  10. console.log(
  11. `${configService.get<string>('APP_NAME')}: Hello from pre save`,
  12. ),
  13. );
  14. return schema;
  15. },
  16. inject: [ConfigService],
  17. },
  18. ]),
  19. ],
  20. })
  21. export class AppModule {}

插件

要向给定的schema中注册插件,可以使用forFeatureAsync()方法。

  1. @Module({
  2. imports: [
  3. MongooseModule.forFeatureAsync([
  4. {
  5. name: 'Cat',
  6. useFactory: () => {
  7. const schema = CatsSchema;
  8. schema.plugin(require('mongoose-autopopulate'));
  9. return schema;
  10. },
  11. },
  12. ]),
  13. ],
  14. })
  15. export class AppModule {}

要向所有schema中立即注册一个插件,调用Connection对象中的.plugin()方法。你可以在所有模型创建前访问连接。使用connectionFactory来实现:

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { MongooseModule } from '@nestjs/mongoose';
  3. @Module({
  4. imports: [
  5. MongooseModule.forRoot('mongodb://localhost/test', {
  6. connectionFactory: (connection) => {
  7. connection.plugin(require('mongoose-autopopulate'));
  8. return connection;
  9. }
  10. }),
  11. ],
  12. })
  13. export class AppModule {}

测试

在单元测试我们的应用程序时,我们通常希望避免任何数据库连接,使我们的测试套件独立并尽可能快地执行它们。但是我们的类可能依赖于从连接实例中提取的模型。如何处理这些类呢?解决方案是创建模拟模型。

为了简化这一过程,@nestjs/mongoose 包公开了一个 getModelToken() 函数,该函数根据一个 token 名称返回一个准备好的[注入token](https://docs.nestjs.com/fundamentals/custom-providers#di-fundamentals)。使用此 token,你可以轻松地使用任何标准自定义提供者技术,包括 useClassuseValueuseFactory。例如:

  1. @@Module({
  2. providers: [
  3. CatsService,
  4. {
  5. provide: getModelToken('Cat'),
  6. useValue: catModel,
  7. },
  8. ],
  9. })
  10. export class CatsModule {}

在本例中,每当任何使用者使用 @InjectModel() 装饰器注入模型时,都会提供一个硬编码的 Model<Cat> (对象实例)。

异步配置

通常,您可能希望异步传递模块选项,而不是事先传递它们。在这种情况下,使用 forRootAsync() 方法,Nest提供了几种处理异步数据的方法。

第一种可能的方法是使用工厂函数:

  1. MongooseModule.forRootAsync({
  2. useFactory: () => ({
  3. uri: 'mongodb://localhost/nest',
  4. }),
  5. });

与其他工厂提供程序一样,我们的工厂函数可以是异步的,并且可以通过注入注入依赖。

  1. MongooseModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useFactory: async (configService: ConfigService) => ({
  4. uri: configService.getString('MONGODB_URI'),
  5. }),
  6. inject: [ConfigService],
  7. });

或者,您可以使用类而不是工厂来配置 MongooseModule,如下所示:

  1. MongooseModule.forRootAsync({
  2. useClass: MongooseConfigService,
  3. });

上面的构造在 MongooseModule中实例化了 MongooseConfigService,使用它来创建所需的 options 对象。注意,在本例中,MongooseConfigService 必须实现 MongooseOptionsFactory 接口,如下所示。 MongooseModule 将在提供的类的实例化对象上调用 createMongooseOptions() 方法。

  1. @Injectable()
  2. class MongooseConfigService implements MongooseOptionsFactory {
  3. createMongooseOptions(): MongooseModuleOptions {
  4. return {
  5. uri: 'mongodb://localhost/nest',
  6. };
  7. }
  8. }

为了防止 MongooseConfigService 内部创建 MongooseModule 并使用从不同模块导入的提供程序,您可以使用 useExisting 语法。

  1. MongooseModule.forRootAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

例子

一个可用的示例见这里

配置

应用程序通常在不同的环境中运行。根据环境的不同,应该使用不同的配置设置。例如,通常本地环境依赖于特定的数据库凭据,仅对本地DB实例有效。生产环境将使用一组单独的DB凭据。由于配置变量会更改,所以最佳实践是将配置变量存储在环境中。

外部定义的环境变量通过 process.env globalNode.js 内部可见。 我们可以尝试通过在每个环境中分别设置环境变量来解决多个环境的问题。 这会很快变得难以处理,尤其是在需要轻松模拟或更改这些值的开发和测试环境中。

Node.js 应用程序中,通常使用 .env 文件,其中包含键值对,其中每个键代表一个特定的值,以代表每个环境。 在不同的环境中运行应用程序仅是交换正确的.env 文件的问题。

Nest 中使用这种技术的一个好方法是创建一个 ConfigModule ,它暴露一个 ConfigService ,根据 $NODE_ENV 环境变量加载适当的 .env 文件。虽然您可以选择自己编写这样的模块,但为方便起见,Nest提供了开箱即用的@ nestjs/config软件包。 我们将在本章中介绍该软件包。

安装

要开始使用它,我们首先安装所需的依赖项。

  1. $ npm i --save @nestjs/config

?> 注意 @nestjs/config 内部使用 dotenv 实现。

开始使用

安装完成之后,我们需要导入ConfigModule模块。通常,我们在根模块AppModule中导入它,并使用。forRoot()静态方法导入它的配置。

  1. import * as dotenv from 'dotenv'; @@filename(app.module)
  2. import * as fs from 'fs'; import { Module } from '@nestjs/common';
  3. import { ConfigModule } from '@nestjs/config';
  4. export class ConfigService { @Module({
  5. private readonly envConfig: Record<string, string>; imports: [ConfigModule.forRoot()],
  6. })
  7. export class AppModule {}

上述代码将从默认位置(项目根目录)载入并解析一个.env文件,从.env文件和process.env合并环境变量键值对,并将结果存储到一个可以通过ConfigService访问的私有结构。forRoot()方法注册了ConfigService提供者,后者提供了一个get()方法来读取这些解析/合并的配置变量。由于@nestjs/config依赖dotenv,它使用该包的规则来处理冲突的环境变量名称。当一个键同时作为环境变量(例如,通过操作系统终端如export DATABASE_USER=test导出)存在于运行环境中以及.env文件中时,以运行环境变量优先。

一个样例.env文件看起来像这样:

  1. DATABASE_USER=test
  2. DATABASE_PASSWORD=test
  3. #### 自定义 env 文件路径
  4. 默认情况下,程序在应用程序的根目录中查找`.env`文件。 要为`.env`文件指定另一个路径,请配置`forRoot()`的配置对象envFilePath属性(可选),如下所示:
  5. ```typescript ```typescript
  6. import { Module } from '@nestjs/common'; ConfigModule.forRoot({
  7. import { ConfigService } from './config.service'; envFilePath: '.development.env',
  8. });

您还可以像这样为.env文件指定多个路径:

  1. ConfigModule.forRoot({
  2. envFilePath: ['.env.development.local', '.env.development'],
  3. });

如果在多个文件中发现同一个变量,则第一个变量优先。

禁止加载环境变量

如果您不想加载.env文件,而是想简单地从运行时环境访问环境变量(如OS shell导出,例如export DATABASE_USER = test),则将options对象的ignoreEnvFile属性设置为true,如下所示 :

  1. ConfigModule.forRoot({
  2. ignoreEnvFile: true,
  3. });

全局使用

当您想在其他模块中使用ConfigModule时,需要将其导入(这是任何Nest模块的标准配置)。 或者,通过将options对象的isGlobal属性设置为true,将其声明为全局模块,如下所示。 在这种情况下,将ConfigModule加载到根模块(例如AppModule)后,您无需在其他模块中导入它。

  1. ConfigModule.forRoot({
  2. isGlobal: true,
  3. });

自定义配置文件

对于更复杂的项目,您可以利用自定义配置文件返回嵌套的配置对象。 这使您可以按功能对相关配置设置进行分组(例如,与数据库相关的设置),并将相关设置存储在单个文件中,以帮助独立管理它们

自定义配置文件导出一个工厂函数,该函数返回一个配置对象。配置对象可以是任意嵌套的普通JavaScript对象。process.env对象将包含完全解析的环境变量键/值对(具有如上所述的.env文件和已解析和合并的外部定义变量)。因为您控制了返回的配置对象,所以您可以添加任何必需的逻辑来将值转换为适当的类型、设置默认值等等。例如:

  1. @@filename(config/configuration)
  2. export default () => ({
  3. port: parseInt(process.env.PORT, 10) || 3000,
  4. database: {
  5. host: process.env.DATABASE_HOST,
  6. port: parseInt(process.env.DATABASE_PORT, 10) || 5432
  7. }
  8. });

我们使用传递给ConfigModule.forRoot()方法的options对象的load属性来加载这个文件:

  1. import configuration from './config/configuration';
  2. @Module({ @Module({
  3. providers: [ imports: [
  4. { ConfigModule.forRoot({
  5. provide: ConfigService, load: [configuration],
  6. useValue: new ConfigService(`${process.env.NODE_ENV || 'development'}.env`), }),
  7. },
  8. ], ],
  9. exports: [ConfigService],
  10. }) })
  11. export class ConfigModule {} export class AppModule {}
  12. ```

ConfigModule 注册一个 ConfigService ,并将其导出为在其他消费模块中可见。此外,我们使用 useValue 语法(参见自定义提供程序)来传递到 .env 文件的路径。此路径将根据 NODE_ENV 环境变量中包含的实际执行环境而不同(例如,’开发’、’生产’等)。 > info 注意 分配给load属性的值是一个数组,允许您加载多个配置文件 (e.g. load: [databaseConfig, authConfig])

使用 ConfigService

现在您可以简单地在任何地方注入 ConfigService ,并根据传递的密钥检索特定的配置值。 要从 ConfigService 访问环境变量,我们需要注入它。因此我们首先需要导入该模块。与任何提供程序一样,我们需要将其包含模块ConfigModule导入到将使用它的模块中(除非您将传递给ConfigModule.forRoot()方法的options对象中的isGlobal属性设置为true)。 如下所示将其导入功能模块。

development.env typescript feature.module.ts @Module({ imports: [ConfigModule], ... }) ``` DATABASE_USER = test;
DATABASE_PASSWORD = test; 然后我们可以使用标准的构造函数注入:

  1. constructor(private configService: ConfigService) {}
  2. ```

使用 ConfigService 并在我们的类中使用它:

要从 ConfigService 访问环境变量,我们需要注入它。因此我们首先需要导入该模块。 ```typescript // get an environment variable const dbUser = this.configService.get(‘DATABASE_USER’);

app.module.ts // get a custom configuration value const dbHost = this.configService.get(‘database.host’); ```

如上所示,使用configService.get()方法通过传递变量名来获得一个简单的环境变量。您可以通过传递类型来执行TypeScript类型提示,如上所示(例如,get<string>(…))。get()方法还可以遍历一个嵌套的自定义配置对象(通过自定义配置文件创建,如上面的第二个示例所示)。get()方法还接受一个可选的第二个参数,该参数定义一个默认值,当键不存在时将返回该值,如下所示:

  1. // use "localhost" when "database.host" is not defined
  2. const dbHost = this.configService.get<string>('database.host', 'localhost');

配置命名空间

ConfigModule模块允许您定义和加载多个自定义配置文件,如上面的自定义配置文件所示。您可以使用嵌套的配置对象来管理复杂的配置对象层次结构,如本节所示。或者,您可以使用registerAs()函数返回一个“带名称空间”的配置对象,如下所示:

typescripttypescript @@filename(config/database.config) export default registerAs(‘database’, () => ({ host: process.env.DATABASE_HOST, port: process.env.DATABASE_PORT || 5432 }));

  1. 与自定义配置文件一样,在您的`registerAs()`工厂函数内部,`process.env`对象将包含完全解析的环境变量键/值对(带有`.env`文件和已定义并已合并的外部定义变量)
  2. > info **注意** `registerAs` 函数是从 `@nestjs/config` 包导出的。
  3. 使用`forRott()``load`方法载入命名空间的配置,和载入自定义配置文件方法相同:
  4. ```typescript
  5. import databaseConfig from './config/database.config';
  6. @Module({ @Module({
  7. imports: [ConfigModule], imports: [
  8. ... ConfigModule.forRoot({
  9. load: [databaseConfig],
  10. }),
  11. ],
  12. }) })
  13. export class AppModule {}
  14. ```

然后我们可以使用标准的构造函数注入,并在我们的类中使用它: 现在,要从数据库命名空间获取host的值,请使用符号.。使用'database'作为属性名称的前缀,该属性名称对应于命名空间的名称(作为传递给registerAs()函数的第一个参数)

app.service.ts

  1. const dbHost = this.configService.get<string>('database.host');

一个合理的替代方案是直接注入'database'的命名空间,我们将从强类型中获益:

  1. import { Injectable } from '@nestjs/common'; constructor(
  2. import { ConfigService } from './config/config.service'; @Inject(databaseConfig.KEY)
  3. private databaseConfig: ConfigType<typeof databaseConfig>,
  4. ) {}

?> info 注意 ConfigType 函数是从 @nestjs/config 包导出的。

  1. @Injectable()
  2. export class AppService {
  3. private isAuthEnabled: boolean; #### 部分注册
  4. constructor(config: ConfigService) {
  5. // Please take note that this check is case sensitive! 到目前为止,我们已经使用`forRoot()`方法在根模块(例如,`AppModule`)中处理了配置文件。也许您有一个更复杂的项目结构,其中特定于功能的配置文件位于多个不同的目录中。与在根模块中加载所有这些文件不同,`@nestjs/config`包提供了一个称为部分注册的功能,它只引用与每个功能模块相关联的配置文件。使用特性模块中的`forFeature()`静态方法来执行部分注册,如下所示:
  6. this.isAuthEnabled = config.get('IS_AUTH_ENABLED') === 'true';
  7. } ```typescript
  8. import databaseConfig from './config/database.config';
  9. @Module({
  10. imports: [ConfigModule.forFeature(databaseConfig)],
  11. })
  12. export class DatabaseModule {}
  13. ```

?> 您可以选择将 ConfigModule 声明为全局模块,而不是在每个模块中导入 ConfigModule。 > info 警告在某些情况下,您可能需要使用onModuleInit()钩子通过部分注册来访问加载的属性,而不是在构造函数中。这是因为forFeature()方法是在模块初始化期间运行的,而模块初始化的顺序是不确定的。如果您以这种方式访问由另一个模块在构造函数中加载的值,则配置所依赖的模块可能尚未初始化。onModuleInit()方法只在它所依赖的所有模块被初始化之后运行,因此这种技术是安全的

Schema验证

一个标准实践是如果在应用启动过程中未提供需要的环境变量或它们不满足特定的验证规则时抛出异常。@nestjs/config包让我们可以使用Joi npm包来提供这种类型验证。使用Joi,你可以定义一个对象Schema对象并验证对应的JavaScript对象。

warning 注意 最新版本的“@hapi/joi”要求您运行Node v12或更高版本。对于较老版本的node,请安装“v16.1.8”。这主要是在“v17.0.2”发布之后,它会在构建期间导致错误。更多信息请参考他们的文档github issue config.service.ts 现在,我们可以定义一个Joi验证模式,并通过forRoot()方法的options对象的validationSchema属性传递它,如下所示

typescripttypescript import as dotenv from ‘dotenv’; app.module.ts import as Joi from ‘@hapi/joi’; import as Joi from ‘@hapi/joi’; import as fs from ‘fs’;
export type EnvConfig = Record; @Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ NODE_ENV: Joi.string() .valid(‘development’, ‘production’, ‘test’, ‘provision’) .default(‘development’), PORT: Joi.number().default(3000), }), }), ], }) export class AppModule {}

  1. export class ConfigService { 由于我们为 `NODE_ENV` `PORT` 设置了默认值,因此如果不在环境文件中提供这些变量,验证将不会失败。然而, 我们需要明确提供 `API_AUTH_ENABLED`。如果我们的 `.env` 文件中的变量不是模式( `schema` )的一部分, 则验证也会引发错误。此外,`Joi` 还会尝试将 `env` 字符串转换为正确的类型。
  2. private readonly envConfig: EnvConfig;
  3. constructor(filePath: string) { 默认情况下,允许使用未知的环境变量(其键不在模式中出现的环境变量),并且不会触发验证异常。默认情况下,将报告所有验证错误。您可以通过通过`forRoot()` options对象的`validationOptions`键传递一个options对象来更改这些行为。此选项对象可以包含由Joi验证选项提供的任何标准验证选项属性。例如,要反转上面的两个设置,像这样传递选项:
  4. > app.module.ts
  5. ```typescript
  6. import * as Joi from '@hapi/joi';
  7. @Module({
  8. imports: [
  9. ConfigModule.forRoot({
  10. validationSchema: Joi.object({
  11. NODE_ENV: Joi.string()
  12. .valid('development', 'production', 'test', 'provision')
  13. .default('development'),
  14. PORT: Joi.number().default(3000),
  15. }),
  16. validationOptions: {
  17. allowUnknown: false,
  18. abortEarly: true,
  19. },
  20. }),
  21. ],
  22. })
  23. export class AppModule {}

@nestjs/config包使用默认设置:

  • allowUnknown:控制是否允许环境变量中未知的键。默认为true
  • abortEarly:如果为true,在遇到第一个错误时就停止验证;如果为false,返回所有错误。默认为false

注意,一旦您决定传递validationOptions对象,您没有显式传递的任何设置都将默认为Joi标准默认值(而不是@nestjs/config默认值)。例如,如果在自定义validationOptions对象中保留allowUnknowns未指定,它的Joi默认值将为false。因此,在自定义对象中指定这两个设置可能是最安全的。

自定义 getter 函数

ConfigService定义了一个通用的get()方法来通过键检索配置值。我们还可以添加getter函数来启用更自然的编码风格:

typescripttypescript get isApiAuthEnabled(): boolean { @Injectable() return Boolean(this.envConfig.API_AUTH_ENABLED); export class ApiConfigService { constructor(private configService: ConfigService) {} get isAuthEnabled(): boolean { return this.configService.get(‘AUTH_ENABLED’) === ‘true’; } } }

  1. @Dependencies(ConfigService)
  2. @Injectable()
  3. export class ApiConfigService {
  4. constructor(configService) {
  5. this.configService = configService;
  6. }
  7. 现在我们可以像下面这样使用getter函数: get isAuthEnabled() {
  8. return this.configService.get('AUTH_ENABLED') === 'true';
  9. }
  10. }

app.service.ts 现在我们可以像下面这样使用getter函数:

  1. app.service.ts
  2. @Injectable() @Injectable()
  3. export class AppService { export class AppService {
  4. constructor(config: ConfigService) { constructor(apiConfigService: ApiConfigService) {
  5. if (config.isApiAuthEnabled) { if (apiConfigService.isAuthEnabled) {
  6. // Authorization is enabled // Authentication is enabled
  7. } }
  8. } }
  9. } }

扩展变量

@nestjs/config包支持环境变量扩展。使用这种技术,您可以创建嵌套的环境变量,其中一个变量在另一个变量的定义中引用。例如:

  1. APP_URL=mywebsite.com
  2. SUPPORT_EMAIL=support@${APP_URL}

通过这种构造,变量SUPPORT_EMAIL解析为support@mywebsite.com。注意${…}语法来触发解析变量APP_URLSUPPORT_EMAIL定义中的值。

info 提示 对于这个特性,@nestjs/config包内部使用dotenv-expand实现。 使用传递给ConfigModuleforRoot()方法的options对象中的expandVariables属性来启用环境变量展开,如下所示:

  1. app.module.ts
  2. @Module({
  3. imports: [
  4. ConfigModule.forRoot({
  5. // ...
  6. expandVariables: true,
  7. }),
  8. ],
  9. })
  10. export class AppModule {}

main.ts中使用

虽然我们的配置是存储在服务中的,但它仍然可以在main.ts文件中使用。通过这种方式,您可以使用它来存储诸如应用程序端口或CORS主机之类的变量。

要访问它,您必须使用app.get()方法,然后是服务引用:

  1. const configService = app.get(ConfigService);

然后你可以像往常一样使用它,通过调用带有配置键的get方法:

  1. const port = configService.get('PORT');

验证

验证网络应用中传递的任何数据是一种最佳实践。为了自动验证传入请求,Nest提供了几个开箱即用的管道。

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe

验证是任何现有 Web 应用程序的基本功能。为了自动验证传入请求,Nest 提供了一个内置的 ValidationPipe ,它使用了功能强大的class-validator包及其声明性验证装饰器。 ValidationPipe 提供了一种对所有传入的客户端有效负载强制执行验证规则的便捷方法,其中在每个模块的本地类/ DTO 声明中使用简单的注释声明特定的规则。

概览

Pipes 一章中,我们完成了构建简化验证管道的过程。为了更好地了解我们在幕后所做的工作,我们强烈建议您阅读本文。在这里,我们将重点讨论 ValidationPipe 的各种实际用例,并使用它的一些高级定制特性。

使用内置的ValidationPipe

?> ValidationPipe@nestjs/common包导入。

由于此管道使用了class-validatorclass-transformer库,因此有许多可用的选项。通过传递给管道的配置对象来进行配置。依照下列内置的选项:

  1. export interface ValidationPipeOptions extends ValidatorOptions {
  2. transform?: boolean;
  3. disableErrorMessages?: boolean;
  4. exceptionFactory?: (errors: ValidationError[]) => any;
  5. }

所有可用的class-validator选项(继承自ValidatorOptions接口):

选项 类型 描述
skipMissingProperties boolean 如果设置为true,验证将跳过对所有验证对象中没有的属性的验证
whitelist boolean 如果设置为true,验证器将去掉没有使用任何验证装饰器的属性的验证(返回的)对象
forbidNonWhitelisted boolean 如果设置为true,验证器不会去掉非白名单的属性,而是会抛出异常
forbidUnknownValues boolean 如果设置为true,尝试验证未知对象会立即失败
disableErrorMessage boolean 如果设置为true,验证错误不会返回给客户端
errorHttpStatusCode number 这个设置允许你确定在错误时使用哪个异常类型。默认抛出BadRequestException
exceptionFactory Function 接受一个验证错误数组并返回一个要抛出的异常对象
groups string[] 验证对象时使用的分组
dismissDefaultMessages boolean 如果设置为true,将不会使用默认消息验证,如果不设置,错误消息会始终是undefined
validationError.target boolean 确定目标是否要在ValidationError中暴露出来
validationError.value boolean 确定验证值是否要在ValidationError中暴露出来

?> 更多关于class-validator包的内容见项目仓库

自动验证

为了本教程的目的,我们将绑定 ValidationPipe 到整个应用程序,因此,将自动保护所有接口免受不正确的数据的影响。

  1. async function bootstrap() {
  2. const app = await NestFactory.create(ApplicationModule);
  3. app.useGlobalPipes(new ValidationPipe());
  4. await app.listen(3000);
  5. }
  6. bootstrap();

要测试我们的管道,让我们创建一个基本接口。

  1. @Post()
  2. create(@Body() createUserDto: CreateUserDto) {
  3. return 'This action adds a new user';
  4. }

?> 由于Typescript没有保存泛型或接口的元数据。当你在你的DTO中使用他们的时候。ValidationPipe可能不能正确验证输入数据。出于这种原因,可以考虑在你的DTO中使用具体的类。

现在我们可以在 CreateUserDto 中添加一些验证规则。我们使用 class-validator 包提供的装饰器来实现这一点,这里有详细的描述。以这种方式,任何使用 CreateUserDto 的路由都将自动执行这些验证规则。

  1. import { IsEmail, IsNotEmpty } from 'class-validator';
  2. export class CreateUserDto {
  3. @IsEmail()
  4. email: string;
  5. @IsNotEmpty()
  6. password: string;
  7. }

有了这些规则,当某人使用无效 email 执行对我们的接口的请求时,则应用程序将自动以 400 Bad Request 代码以及以下响应正文进行响应:

  1. {
  2. "statusCode": 400,
  3. "error": "Bad Request",
  4. "message": ["email must be an email"]
  5. }

除了验证请求主体之外,ValidationPipe 还可以与其他请求对象属性一起使用。假设我们希望接受端点路径中的 id 。为了确保此请求参数只接受数字,我们可以使用以下结构:

  1. @Get(':id')
  2. findOne(@Param() params: FindOneParams) {
  3. return 'This action returns a user';
  4. }

DTO 一样,FindOneParams 只是一个使用 class-validator 定义验证规则的类。它是这样的:

  1. import { IsNumberString } from 'class-validator';
  2. export class FindOneParams {
  3. @IsNumberString()
  4. id: number;
  5. }

禁用详细错误

错误消息有助于解释请求中的错误。然而,一些生产环境倾向于禁用详细的错误。通过向 ValidationPipe 传递一个 options 对象来做到这一点:

  1. app.useGlobalPipes(
  2. new ValidationPipe({
  3. disableErrorMessages: true,
  4. })
  5. );

现在,不会将错误消息返回给最终用户。

剥离属性

我们的 ValidationPipe 还可以过滤掉方法处理程序不应该接收的属性。在这种情况下,我们可以对可接受的属性进行白名单,白名单中不包含的任何属性都会自动从结果对象中删除。例如,如果我们的处理程序需要 emailpassword,但是一个请求还包含一个 age 属性,那么这个属性可以从结果 DTO 中自动删除。要启用这种行为,请将白名单设置为 true

  1. app.useGlobalPipes(
  2. new ValidationPipe({
  3. whitelist: true,
  4. })
  5. );

当设置为 true 时,这将自动删除非白名单属性(在验证类中没有任何修饰符的属性)。

或者,您可以在出现非白名单属性时停止处理请求,并向用户返回错误响应。要启用此选项,请将 forbidNonWhitelisted 选项属性设置为 true ,并将白名单设置为 true

负载对象转换(Transform)

来自网络的有效负载是普通的 JavaScript 对象。ValidationPipe 可以根据对象的 DTO 类自动将有效负载转换为对象类型。若要启用自动转换,请将transform设置为 true。这可以在方法级别使用:

cats.control.ts

  1. @Post()
  2. @UsePipes(new ValidationPipe({ transform: true }))
  3. async create(@Body() createCatDto: CreateCatDto) {
  4. this.catsService.create(createCatDto);
  5. }

要全局使能这一行为,将选项设置到一个全局管道中:

  1. app.useGlobalPipes(
  2. new ValidationPipe({
  3. transform: true,
  4. })
  5. );

要使能自动转换选项,ValidationPipe将执行简单类型转换。在下述示例中,findOne()方法调用一个从地址参数中解析出的id参数。

  1. @Get(':id')
  2. findOne(@Param('id') id: number) {
  3. console.log(typeof id === 'number'); // true
  4. return 'This action returns a user';
  5. }

默认地,每个地址参数和查询参数在网络传输时都是string类型。在上述示例中,我们指定id参数为number(在方法签名中)。因此,ValidationPipe会自动将string类型转换为number

显式转换

在上述部分,我们演示了ValidationPipe如何基于期待类型隐式转换查询和路径参数,然而,这一特性需要开启自动转换功能。

可选地(在不开启自动转换功能的情况下),你可以使用ParseIntPipe或者ParseBoolPipe显式处理值(注意,没有必要使用ParseStringPipe,这是因为如前所述的,网络中传输的路径参数和查询参数默认都是string类型)。

  1. @Get(':id')
  2. findOne(
  3. @Param('id', ParseIntPipe) id: number,
  4. @Query('sort', ParseBoolPipe) sort: boolean,
  5. ) {
  6. console.log(typeof id === 'number'); // true
  7. console.log(typeof sort === 'boolean'); // true
  8. return 'This action returns a user';
  9. }

?> ParseIntPipeParseBoolPipe@nestjs/common包中导出。

转换和验证数组

TypeScript不存储泛型或接口的元数据,因此当你在DTO中使用它们的时候,ValidationPipe可能不能正确验证输入数据。例如,在下列代码中,createUserDto不能正确验证。

  1. @Post()
  2. createBulk(@Body() createUserDtos: CreateUserDto[]) {
  3. return 'This action adds new users';
  4. }

要验证数组,创建一个包裹了该数组的专用类,或者使用ParseArrayPipe

  1. @Post()
  2. createBulk(
  3. @Body(new ParseArrayPipe({ items: CreateUserDto }))
  4. createUserDtos: CreateUserDto[],
  5. ) {
  6. return 'This action adds new users';
  7. }

此外,ParseArrayPipe可能需要手动解析查询参数。让我们考虑一个返回作为查询参数传递的标识的usersfindByIds()方法:

  1. @Get()
  2. findByIds(
  3. @Query('id', new ParseArrayPipe({ items: Number, separator: ',' }))
  4. ids: number[],
  5. ) {
  6. return 'This action returns users by ids';
  7. }

这个构造用于验证一个来自如下形式带参数的GET请求:

  1. GET /?ids=1,2,3

Websockets和 微服务

尽管本章展示了使用 HTTP 风格的应用程序的例子(例如,ExpressFastify ), ValidationPipe 对于 WebSockets 和微服务是一样的,不管使用什么传输方法。

学到更多

要阅读有关自定义验证器,错误消息和可用装饰器的更多信息,请访问此页面

高速缓存(Caching)

缓存是一项伟大而简单的技术,可以帮助提高应用程序的性能。它充当临时数据存储,提供高性能的数据访问。

安装

我们首先需要安装所需的包:

  1. $ npm install --save cache-manager

内存缓存

Nest为各种缓存存储提供程序提供了统一的 API。内置的是内存中的数据存储。但是,您可以轻松地切换到更全面的解决方案,比如 Redis 。为了启用缓存,首先导入 CacheModule 并调用它的 register() 方法。

  1. import { CacheModule, Module } from '@nestjs/common';
  2. import { AppController } from './app.controller';
  3. @Module({
  4. imports: [CacheModule.register()],
  5. controllers: [AppController],
  6. })
  7. export class ApplicationModule {}

!> 在[GraphQL](https://docs.nestjs.com/graphql/quick-start)应用中,拦截器针对每个字段处理器分别运行,因此,CacheModule(使用)

然后将 CacheInterceptor 绑定到需要缓存数据的地方。

  1. @Controller()
  2. @UseInterceptors(CacheInterceptor)
  3. export class AppController {
  4. @Get()
  5. findAll(): string[] {
  6. return [];
  7. }
  8. }

!> 警告: 只有使用 GET 方式声明的节点会被缓存。此外,注入本机响应对象( @Res() )的 HTTP 服务器路由不能使用缓存拦截器。有关详细信息,请参见响应映射

全局缓存

为了减少重复代码量,可以一次绑定 CacheInterceptor 到每个现有节点:

  1. import { CacheModule, Module, CacheInterceptor } from '@nestjs/common';
  2. import { AppController } from './app.controller';
  3. import { APP_INTERCEPTOR } from '@nestjs/core';
  4. @Module({
  5. imports: [CacheModule.register()],
  6. controllers: [AppController],
  7. providers: [
  8. {
  9. provide: APP_INTERCEPTOR,
  10. useClass: CacheInterceptor,
  11. },
  12. ],
  13. })
  14. export class ApplicationModule {}

定制缓存

所有缓存的数据有其自己的过期时间(TTL)。要个性化不同值,将选项对象传递给register()方法。

  1. CacheModule.register({
  2. ttl:5, //秒
  3. max:10, //缓存中最大和最小数量
  4. });

全局缓存重载

使能全局缓存后,缓存入口存储在基于路径自动生成的Cachekey中。你可能需要基于每个方法重载特定的缓存设置(@CacheKey()@CacheTTL()),允许为独立控制器方法自定义缓存策略。这在使用不同存储缓存时是最有意义的。

  1. @Controller()
  2. export class AppController {
  3. @CacheKey('custom_key')
  4. @CacheTTL(20)
  5. findAll(): string[] {
  6. return [];
  7. }
  8. }

?> @CacheKey()@CacheTTL()装饰器从@nestjs/common包导入。

@CacheKey()装饰器可以有或者没有一个对应的@CacheTTL()装饰器,反之亦然。你可以选择仅覆盖@CacheKey()@CacheTTL()。没有用装饰器覆盖的设置将使用全局注册的默认值(见自定义缓存)。

WebSockets 和 微服务

显然,您可以毫不费力地使用 CacheInterceptor WebSocket 订阅者模式以及 Microservice 的模式(无论使用何种服务间的传输方法)。

?> 译者注: 微服务架构中服务之间的调用需要依赖某种通讯协议介质,在 nest 中不限制你是用消息队列中间件,RPC/gRPC 协议或者对外公开 APIHTTP 协议。

  1. @CacheKey('events')
  2. @UseInterceptors(CacheInterceptor)
  3. @SubscribeMessage('events')
  4. handleEvent(client: Client, data: string[]): Observable<string[]> {
  5. return [];
  6. }

然而,需要一个附加的@CacheKey()装饰器来指定一个用于依次存储并获取缓存数据的键。注意,你不应该缓存所有的内容。永远也不要去缓存那些用于实现业务逻辑也不是简单地查询数据的行为。

此外,你可以使用@CacheTTL()装饰器来指定一个缓存过期时间(TTL),用于覆盖全局默认的TTL值。

  1. @CacheTTL(10)
  2. @UseInterceptors(CacheInterceptor)
  3. @SubscribeMessage('events')
  4. handleEvent(client: Client, data: string[]): Observable<string[]> {
  5. return [];
  6. }

?> @CacheTTL()装饰器可以@CacheKey()装饰器同时或者不同时使用。

不同的存储

服务在底层使用缓存管理器(cache-manager)cache-manager包支持一个宽范围的可用存储,例如,Redis存储。一个完整的支持存储列表见这里。要设置Redis存储,简单地将该包和相应的选项传递给register()方法。

  1. import * as redisStore from 'cache-manager-redis-store';
  2. import { CacheModule, Module } from '@nestjs/common';
  3. import { AppController } from './app.controller';
  4. @Module({
  5. imports: [
  6. CacheModule.register({
  7. store: redisStore,
  8. host: 'localhost',
  9. port: 6379,
  10. }),
  11. ],
  12. controllers: [AppController],
  13. })
  14. export class ApplicationModule {}

调整追踪

默认地,Nest使用请求URL(在一个HTTPapp中)或者缓存键(在websocketsmicroservices应用中,通过@CacheKey()装饰器设置)来联系缓存记录和路径。然而,有时你可能想要根据不同要素设置追踪,例如HTTP headers(比如,确定合适profile路径的Authorization)。

为了达到这个目的,创建一个CacheInterceptor的子类并覆盖trackBy()方法。

  1. @Injectable()
  2. class HttpCacheInterceptor extends CacheInterceptor {
  3. trackBy(context: ExecutionContext): string | undefined {
  4. return 'key';
  5. }
  6. }

异步配置

你可能想异步传递模块选项来代替在编译时静态传递。在这种情况下,可以使用registerAsync()方法,它提供了不同的处理异步配置的方法。

一个方法是使用工厂函数:

  1. CacheModule.registerAsync({
  2. useFactory: () => ({
  3. ttl: 5,
  4. }),
  5. });

我们的工厂行为和其他异步模块工厂一样(它可以使用inject异步注入依赖)。

  1. CacheModule.registerAsync({
  2. imports: [ConfigModule],
  3. useFactory: async (configService: ConfigService) => ({
  4. ttl: configService.getString('CACHE_TTL'),
  5. }),
  6. inject: [ConfigService],
  7. });

此外,你也可以使用useClass方法:

  1. CacheModule.registerAsync({
  2. useClass: CacheConfigService,
  3. });

上述构造器将在CacheModule内部实例化CacheConfigService并用它来得到选项对象,CacheConfigService需要使用CacheOptionsFactory接口来提供配置选项:

  1. @Injectable()
  2. class CacheConfigService implements CacheOptionsFactory {
  3. createCacheOptions(): CacheModuleOptions {
  4. return {
  5. ttl: 5,
  6. };
  7. }
  8. }

如果你希望使用在其他不同模块中导入的现有的配置提供者,使用useExisting语法:

  1. CacheModule.registerAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

这和useClass工作模式相同,但有一个根本区别——CacheModule将查找导入的模块来重用任何已经创建的ConfigService,以代替自己创实例化。

?> 提示: @CacheKey() 装饰器来源于 @nestjs/common 包。

但是, @CacheKey() 需要附加装饰器以指定用于随后存储和检索缓存数据的密钥。此外,请注意,开发者不应该缓存所有内容。缓存数据是用来执行某些业务操作,而一些简单数据查询是不应该被缓存的。

自定义缓存

所有缓存数据都有自己的到期时间(TTL)。要自定义默认值,请将配置选项填写在 register()方法中。

  1. CacheModule.register({
  2. ttl: 5, // seconds
  3. max: 10, // maximum number of items in cache
  4. });

不同的缓存库

我们充分利用了缓存管理器。该软件包支持各种实用的商店,例如Redis商店(此处列出完整列表)。要设置 Redis 存储,只需将包与 correspoding 选项一起传递给 register() 方法即可。

?> 译者注: 缓存方案库目前可选的有 redis, fs, mongodb, memcached 等。

  1. import * as redisStore from 'cache-manager-redis-store';
  2. import { CacheModule, Module } from '@nestjs/common';
  3. import { AppController } from './app.controller';
  4. @Module({
  5. imports: [
  6. CacheModule.register({
  7. store: redisStore,
  8. host: 'localhost',
  9. port: 6379,
  10. }),
  11. ],
  12. controllers: [AppController],
  13. })
  14. export class ApplicationModule {}

调整跟踪

默认情况下, Nest 通过 @CacheKey() 装饰器设置的请求路径(在 HTTP 应用程序中)或缓存中的 key(在 websockets 和微服务中)来缓存记录与您的节点数据相关联。然而有时您可能希望根据不同因素设置跟踪,例如,使用 HTTP 头部字段(例如 Authorization 字段关联身份鉴别节点服务)。

为此,创建 CacheInterceptor 的子类并覆盖 trackBy() 方法。

  1. @Injectable()
  2. class HttpCacheInterceptor extends CacheInterceptor {
  3. trackBy(context: ExecutionContext): string | undefined {
  4. return 'key';
  5. }
  6. }

异步配置

通常,您可能希望异步传递模块选项,而不是事先传递它们。在这种情况下,使用 registerAsync() 方法,提供了几种处理异步数据的方法。

第一种可能的方法是使用工厂函数:

  1. CacheModule.registerAsync({
  2. useFactory: () => ({
  3. ttl: 5,
  4. }),
  5. });

显然,我们的工厂要看起来能让每一个调用用使用。(可以变成顺序执行的同步代码,并且能够通过注入依赖使用)。

  1. CacheModule.registerAsync({
  2. imports: [ConfigModule],
  3. useFactory: async (configService: ConfigService) => ({
  4. ttl: configService.getString('CACHE_TTL'),
  5. }),
  6. inject: [ConfigService],
  7. });

或者,您可以使用类而不是工厂:

  1. CacheModule.registerAsync({
  2. useClass: CacheConfigService,
  3. });

上面的构造将 CacheConfigService 在内部实例化为 CacheModule ,并将利用它来创建选项对象。在 CacheConfigService 中必须实现 CacheOptionsFactory 的接口。

  1. @Injectable()
  2. class CacheConfigService implements CacheOptionsFactory {
  3. createCacheOptions(): CacheModuleOptions {
  4. return {
  5. ttl: 5,
  6. };
  7. }
  8. }

为了防止 CacheConfigService 内部创建 CacheModule 并使用从不同模块导入的提供程序,您可以使用 useExisting 语法。

  1. CacheModule.registerAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

它和 useClass 的用法有一个关键的相同点: CacheModule 将查找导入的模块以重新使用已创建的 ConfigService 实例,而不是重复实例化。

序列化(Serialization)

序列化(Serialization)是一个在网络响应中返回对象前的过程。 这是一个适合转换和净化要返回给客户的数据的地方。例如,应始终从最终响应中排除敏感数据(如用户密码)。此外,某些属性可能需要额外的转换,比方说,我们只想发送一个实体的子集。手动完成这些转换既枯燥又容易出错,并且不能确定是否覆盖了所有的情况。

?> 译者注: Serialization 实现可类比 composer 库中 fractal ,响应给用户的数据不仅仅要剔除设计安全的属性,还需要剔除一些无用字段如 create_time, delete_time,update_time 和其他属性。在 JAVA 的实体类中定义 N 个属性的话就会返回 N 个字段,解决方法可以使用范型编程,否则操作实体类回影响数据库映射字段。

概要

为了提供一种直接的方式来执行这些操作, Nest 附带了这个 ClassSerializerInterceptor 类。它使用类转换器来提供转换对象的声明性和可扩展方式。基于此类基础下,可以从类转换器中获取方法和调用 classToPlain() 函数返回的值。要这样做,可以将由class-transformer装饰器提供的规则应用在实体/DTO类中,如下所示:

排除属性

我们假设要从一个用户实体中自动排除password属性。我们给实体做如下注释:

  1. import { Exclude } from 'class-transformer';
  2. export class UserEntity {
  3. id: number;
  4. firstName: string;
  5. lastName: string;
  6. @Exclude()
  7. password: string;
  8. constructor(partial: Partial<UserEntity>) {
  9. Object.assign(this, partial);
  10. }
  11. }

然后,直接在控制器的方法中调用就能获得此类的实例。

  1. @UseInterceptors(ClassSerializerInterceptor)
  2. @Get()
  3. findOne(): UserEntity {
  4. return new UserEntity({
  5. id: 1,
  6. firstName: 'Kamil',
  7. lastName: 'Mysliwiec',
  8. password: 'password',
  9. });
  10. }

!> 我们必须返回一个类的实体。如果你返回一个普通的JavaScript对象,例如,{user: new UserEntity()},该对象将不会被正常序列化。

?> 提示: @ClassSerializerInterceptor() 装饰器来源于 @nestjs/common 包。

现在当你调用此服务时,将收到以下响应结果:

  1. {
  2. "id": 1,
  3. "firstName": "Kamil",
  4. "lastName": "Mysliwiec"
  5. }

注意,拦截器可以应用于整个应用程序(见这里)。拦截器和实体类声明的组合确保返回 UserEntity 的任何方法都将确保删除 password 属性。这给你一个业务规则的强制、集中的评估。

公开属性

您可以使用 @Expose() 装饰器来为属性提供别名,或者执行一个函数来计算属性值(类似于 getter 函数),如下所示。

  1. @Expose()
  2. get fullName(): string {
  3. return `${this.firstName} ${this.lastName}`;
  4. }

变换

您可以使用 @Transform() 装饰器执行其他数据转换。例如,您要选择一个名称 RoleEntity 而不是返回整个对象。

  1. @Transform(role => role.name)
  2. role: RoleEntity;

传递选项

你可能想要修改转换函数的默认行为。要覆盖默认设置,请使用 @SerializeOptions() 装饰器来将其传递给一个options对象。

  1. @SerializeOptions({
  2. excludePrefixes: ['_'],
  3. })
  4. @Get()
  5. findOne(): UserEntity {
  6. return {};
  7. }

?> 提示: @SerializeOptions() 装饰器来源于 @nestjs/common 包。

通过 @SerializeOptions() 传递的选项作为底层 classToPlain() 函数的第二个参数传递。在本例中,我们自动排除了所有以_前缀开头的属性。

Websockets 和 微服务

虽然本章展示了使用 HTTP 风格的应用程序的例子(例如,ExpressFastify ),但是 ClassSerializerInterceptor对于 WebSockets 和微服务的工作方式是一样的,不管使用的是哪种传输方法。

更多

想了解有关装饰器选项的更多信息,请访问此页面

定时任务

定时任务允许你按照指定的日期/时间、一定时间间隔或者一定时间后单次执行来调度(scheduling)任意代码(方法/函数)。在Linux世界中,这经常通过操作系统层面的cron包等执行。在Node.js应用中,有几个不同的包可以模拟cron包的功能。Nest提供了@nestjs/schedule包,其集成了流行的Node.js的node-cron包,我们将在本章中应用该包。

安装

我们首先从安装需要的依赖开始。

  1. $ npm install --save @nestjs/schedule

要激活工作调度,从根AppModule中导入ScheduleModule并运行forRoot()静态方法,如下:

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { ScheduleModule } from '@nestjs/schedule';
  3. @Module({
  4. imports: [
  5. ScheduleModule.forRoot()
  6. ],
  7. })
  8. export class AppModule {}

.forRoot()调用初始化调度器并且注册在你应用中任何声明的cron jobs,timeoutsintervals。注册开始于onApplicationBootstrap生命周期钩子发生时,保证所有模块都已经载入,任何计划工作已经声明。

声明计时工作(cron job)

一个计时工作调度任何函数(方法调用)以自动运行, 计时工作可以:

  • 单次,在指定日期/时间
  • 重复循环:重复工作可以在指定周期中指定执行(例如,每小时,每周,或者每5分钟)

在包含要运行代码的方法定义前使用@Cron()装饰器声明一个计时工作,如下:

  1. import { Injectable, Logger } from '@nestjs/common';
  2. import { Cron } from '@nestjs/schedule';
  3. @Injectable()
  4. export class TasksService {
  5. private readonly logger = new Logger(TasksService.name);
  6. @Cron('45 * * * * *')
  7. handleCron() {
  8. this.logger.debug('Called when the current second is 45');
  9. }
  10. }

在这个例子中,handleCron()方法将在当前时间为45秒时定期执行。换句话说,该方法每分钟执行一次,在第45秒执行。

@Cron()装饰器支持标准的cron patterns:

  • 星号通配符 (也就是 *)
  • 范围(也就是 1-3,5)
  • 步长(也就是 */2)

在上述例子中,我们给装饰器传递了45 * * * * *,下列键展示了每个位置的计时模式字符串的意义:

  1. * * * * * *
  2. | | | | | |
  3. | | | | | day of week
  4. | | | | month
  5. | | | day of month
  6. | | hour
  7. | minute
  8. second (optional)

一些示例的计时模式包括:

名称 含义
每秒
45 * 每分钟第45秒
10 * 每小时,从第10分钟开始
0 /30 9-17 上午9点到下午5点之间每30分钟
0 30 11 1-5 周一至周五上午11:30

@nestjs/schedule包提供一个方便的枚举

  1. import { Injectable, Logger } from '@nestjs/common';
  2. import { Cron, CronExpression } from '@nestjs/schedule';
  3. @Injectable()
  4. export class TasksService {
  5. private readonly logger = new Logger(TasksService.name);
  6. @Cron(CronExpression.EVERY_45_SECONDS)
  7. handleCron() {
  8. this.logger.debug('Called every 45 seconds');
  9. }
  10. }

在本例中,handleCron()方法每45秒执行一次。

可选地,你可以为将一个JavaScriptDate对象传递给@Cron()装饰器。这样做可以让工作在指定日期执行一次。

?> 使用JavaScript日期算法来关联当前日期和计划工作。@Cron(new Date(Date.now()+10*1000))用于在应用启动10秒后运行。

你可以在声明后访问并控制一个定时任务,或者使用动态API动态创建一个定时任务(其定时模式在运行时定义)。要通过API声明定时任务,你必须通过将选项对象中的name属性作为可选的第二个参数传递给装饰器,从而将工作和名称联系起来。

  1. @Cron('* * 8 * * *', {
  2. name: 'notifications',
  3. })
  4. triggerNotifications() {}

声明间隔

要声明一个以一定间隔运行的方法,使用@Interval()装饰器前缀。以毫秒单位的number传递间隔值,如下:

  1. @Interval(10000)
  2. handleInterval() {
  3. this.logger.debug('Called every 10 seconds');
  4. }

?> 本机制在底层使用JavaScriptsetInterval()函数。你也可以使用定期调度工作来应用一个定时任务。

如果你希望在声明类之外通过动态API控制你声明的时间间隔。使用下列结构将名称与间隔关联起来。

  1. @Interval('notifications', 2500)
  2. handleInterval() {}

动态API也支持动态创建时间间隔,间隔属性在运行时定义,可以列出和删除他们。

声明延时任务

要声明一个在指定时间后运行(一次)的方法,使用@Timeout()装饰器前缀。将从应用启动的相关时间偏移量(毫秒)传递给装饰器,如下:

  1. @Timeout(5000)
  2. handleTimeout() {
  3. this.logger.debug('Called once after 5 seconds');
  4. }

?> 本机制在底层使用JavaScript的setTimeout()方法

如果你想要在声明类之外通过动态API控制你声明的超时时间,将超时时间和一个名称以如下结构关联:

  1. @Timeout('notifications', 2500)
  2. handleTimeout() {}

动态API同时支持创建动态超时时间,超时时间在运行时定义,可以列举和删除他们。

动态规划模块API

@nestjs/schedule模块提供了一个支持管理声明定时、超时和间隔任务的动态API。该API也支持创建和管理动态定时、超时和间隔,这些属性在运行时定义。

动态定时任务

使用SchedulerRegistryAPI从你代码的任何地方获取一个CronJob实例的引用。首先,使用标准构造器注入ScheduleRegistry

  1. constructor(private schedulerRegistry: SchedulerRegistry) {}

?> 从@nestjs/schedule包导入SchedulerRegistry

使用下列类,假设通过下列定义声明一个定时任务:

  1. @Cron('* * 8 * * *', {
  2. name: 'notifications',
  3. })
  4. triggerNotifications() {}

如下获取本工作:

  1. const job = this.schedulerRegistry.getCronJob('notifications');
  2. job.stop();
  3. console.log(job.lastDate());

getCronJob()方法返回一个命名的定时任务。然后返回一个包含下列方法的CronJob对象:

  • stop()-停止一个按调度运行的任务
  • start()-重启一个停止的任务
  • setTime(time:CronTime)-停止一个任务,为它设置一个新的时间,然后再启动它
  • lastDate()-返回一个表示工作最后执行日期的字符串
  • nextDates(count:number)-返回一个moment对象的数组(大小count),代表即将执行的任务日期

?> 在moment对象中使用toDate()来渲染成易读的形式。

使用SchedulerRegistry.addCronJob()动态创建一个新的定时任务,如下:

  1. addCronJob(name: string, seconds: string) {
  2. const job = new CronJob(`${seconds} * * * * *`, () => {
  3. this.logger.warn(`time (${seconds}) for job ${name} to run!`);
  4. });
  5. this.scheduler.addCronJob(name, job);
  6. job.start();
  7. this.logger.warn(
  8. `job ${name} added for each minute at ${seconds} seconds!`,
  9. );
  10. }

在这个代码中,我们使用cron包中的CronJob对象来创建定时任务。CronJob构造器采用一个定时模式(类似@Cron()装饰器作为其第一个参数,以及一个将执行的回调函数作为其第二个参数。SchedulerRegistry.addCronJob()方法有两个参数:一个CronJob名称,以及一个CronJob对象自身。

!> 记得在使用前注入SchedulerRegistry,从cron包中导入 CronJob

使用SchedulerRegistry.deleteCronJob()方法删除一个命名的定时任务,如下:

  1. deleteCron(name: string) {
  2. this.scheduler.deleteCronJob(name);
  3. this.logger.warn(`job ${name} deleted!`);
  4. }

使用SchedulerRegistry.getCronJobs()方法列出所有定时任务,如下:

  1. getCrons() {
  2. const jobs = this.scheduler.getCronJobs();
  3. jobs.forEach((value, key, map) => {
  4. let next;
  5. try {
  6. next = value.nextDates().toDate();
  7. } catch (e) {
  8. next = 'error: next fire date is in the past!';
  9. }
  10. this.logger.log(`job: ${key} -> next: ${next}`);
  11. });
  12. }

getCronJobs()方法返回一个map。在这个代码中,我们遍历该map并且尝试获取每个CronJobnextDates()方法。在CronJobAPI中,如果一个工作已经执行了并且没有下一次执行的日期,将抛出异常。

动态间隔

使用SchedulerRegistry.getInterval()方法获取一个时间间隔的引用。如上,使用标准构造注入SchedulerRegistry

  1. constructor(private schedulerRegistry: SchedulerRegistry) {}

如下使用:

  1. const interval = this.schedulerRegistry.getInterval('notifications');
  2. clearInterval(interval);

使用SchedulerRegistry.addInterval()方法创建一个新的动态间隔,如下:

  1. addInterval(name: string, seconds: string) {
  2. const callback = () => {
  3. this.logger.warn(`Interval ${name} executing at time (${seconds})!`);
  4. };
  5. const interval = setInterval(callback, seconds);
  6. this.scheduler.addInterval(name, interval);
  7. }

在该代码中,我们创建了一个标准的JavaScript间隔,然后将其传递给ScheduleRegistry.addInterval()方法。该方法包括两个参数:一个时间间隔的名称,和时间间隔本身。

如下使用SchedulerRegistry.deleteInterval()删除一个命名的时间间隔:

  1. deleteInterval(name: string) {
  2. this.scheduler.deleteInterval(name);
  3. this.logger.warn(`Interval ${name} deleted!`);
  4. }

使用SchedulerRegistry.getIntervals()方法如下列出所有的时间间隔:

  1. getIntervals() {
  2. const intervals = this.scheduler.getIntervals();
  3. intervals.forEach(key => this.logger.log(`Interval: ${key}`));
  4. }

动态超时

使用SchedulerRegistry.getTimeout()方法获取一个超时引用,如上,使用标准构造注入SchedulerRegistry

  1. constructor(private schedulerRegistry: SchedulerRegistry) {}

并如下使用:

  1. const timeout = this.schedulerRegistry.getTimeout('notifications');
  2. clearTimeout(timeout);

使用SchedulerRegistry.addTimeout()方法创建一个新的动态超时,如下:

  1. addTimeout(name: string, seconds: string) {
  2. const callback = () => {
  3. this.logger.warn(`Timeout ${name} executing after (${seconds})!`);
  4. });
  5. const timeout = setTimeout(callback, seconds);
  6. this.scheduler.addTimeout(name, timeout);
  7. }

在该代码中,我们创建了个一个标准的JavaScript超时任务,然后将其传递给ScheduleRegistry.addTimeout()方法,该方法包含两个参数:一个超时的名称,以及超时对象自身。

使用SchedulerRegistry.deleteTimeout()方法删除一个命名的超时,如下:

  1. deleteTimeout(name: string) {
  2. this.scheduler.deleteTimeout(name);
  3. this.logger.warn(`Timeout ${name} deleted!`);
  4. }

使用SchedulerRegistry.getTimeouts()方法列出所有超时任务:

  1. getTimeouts() {
  2. const timeouts = this.scheduler.getTimeouts();
  3. timeouts.forEach(key => this.logger.log(`Timeout: ${key}`));
  4. }

示例

一个可用的例子见这里

压缩

压缩可以大大减小响应主体的大小,从而提高 Web 应用程序的速度。

在大业务量的生产环境网站中,强烈推荐将压缩功能从应用服务器中卸载——典型做法是使用反向代理(例如Nginx)。在这种情况下,你不应该使用压缩中间件。

配合Express使用(默认)

使用压缩中间件启用 gzip 压缩。

首先,安装所需的包:

  1. $ npm i --save compression

安装完成后,将其应用为全局中间件。

  1. import * as compression from 'compression';
  2. // somewhere in your initialization file
  3. app.use(compression());

配合Fastify使用

如果你在使用的是 FastifyAdapter,请考虑使用 fastify-compress

  1. $ npm i --save fastify-compress

安装完成后,将其应用为全局中间件。

  1. import * as compression from 'fastify-compress';
  2. // somewhere in your initialization file
  3. app.register(compression);

默认地,如果浏览器支持编码,fastify-compress使用Brotli压缩(Node>=11.7.0)。Brotli在压缩比方面非常有效,但也非常慢。鉴于此,你可能想告诉fastify-compress仅使用deflategzip来压缩相应,你最终会得到一个较大的相应但是可以传输的更快。

要指定编码,向app.register提供第二个参数:

  1. app.register(compression, { encodings: ['gzip', 'deflate'] });

上述内容告诉fastify-compress仅使用gzip和deflate编码,如果客户端同时支持两种,则以gzip优先。

安全

在本章中,您将学习一些可以提高应用程序安全性的技术。

Helmet

通过适当地设置 HTTP 头,Helmet 可以帮助保护您的应用免受一些众所周知的 Web 漏洞的影响。通常,Helmet 只是14个较小的中间件函数的集合,它们设置与安全相关的 HTTP 头(阅读更多)。首先,安装所需的包:

  1. $ npm i --save helmet

安装完成后,将其应用为全局中间件。

  1. import * as helmet from 'helmet';
  2. // somewhere in your initialization file
  3. app.use(helmet());

CORS

跨源资源共享(CORS)是一种允许从另一个域请求资源的机制。在底层,Nest 使用了 cors 包,它提供了一系列选项,您可以根据自己的要求进行自定义。为了启用 CORS,您必须调用 enableCors() 方法。

  1. const app = await NestFactory.create(ApplicationModule);
  2. app.enableCors();
  3. await app.listen(3000);

enableCors()方法使用一个可选的配置对象参数。该对象的可用属性在其官方CORS文档中有所描述。

可选地,通过create()方法的选项对象使能CORS,设置cors属性为true来使能CORS的默认属性。可选地,传递一个CORS配置对象作为cors属性值来自定义其行为:

  1. const app = await NestFactory.create(ApplicationModule, { cors: true });
  2. await app.listen(3000);

CSRF

跨站点请求伪造(称为 CSRFXSRF)是一种恶意利用网站,其中未经授权的命令从 Web 应用程序信任的用户传输。要减轻此类攻击,您可以使用 csurf 软件包。首先,安装所需的包:

  1. $ npm i --save csurf

?> 正如 csurf 中间件页面所解释的,csurf 模块需要首先初始化会话中间件或 cookie 解析器。有关进一步说明,请参阅该文档

安装完成后,将其应用为全局中间件。

  1. import * as csurf from 'csurf';
  2. // somewhere in your initialization file
  3. app.use(csurf());

限速

为了保护您的应用程序免受暴力攻击,您必须实现某种速率限制。幸运的是,NPM上已经有很多各种中间件可用。其中之一是express-rate-limit

  1. $ npm i --save express-rate-limit

安装完成后,将其应用为全局中间件。

  1. import * as rateLimit from 'express-rate-limit';
  2. // somewhere in your initialization file
  3. app.use(
  4. rateLimit({
  5. windowMs: 15 * 60 * 1000, // 15 minutes
  6. max: 100, // limit each IP to 100 requests per windowMs
  7. }),
  8. );

如果在服务器和以太网之间存在负载均衡或者反向代理,Express可能需要配置为信任proxy设置的头文件,从而保证最终用户得到正确的IP地址。要如此,首先使用NestExpressApplication平台接口来创建你的app实例,然后配置trust proxy设置。

?> 提示: 如果您在 FastifyAdapter 下开发,请考虑使用 fastify-rate-limit

队列

队列是一种有用的设计模式,可以帮助你处理一般应用规模和性能的挑战。一些队列可以帮助你处理的问题示例包括:

  • 平滑输出峰值。例如,如果用户可以在任何时间创建资源敏感型任务,你可以将其添加到一个消息队列中而不是同步执行。然后你可以通过工作者进程从队列中以一个可控的方式取出进程。在应用规模增大时,你可以轻松添加新的队列消费者来提高后端任务处理能力。
  • 将可能阻塞Node.js事件循环的整体任务打碎。例如,如果一个用户请求是CPU敏感型工作,例如音频转码,你可以将其委托给其他进程,从而保证用户接口进程保持响应。
  • 在不同的服务间提供一个可信的通讯通道。例如,你可以将任务(工作)加入一个进程或服务,并由另一个进程或服务来消费他们。你可以在由其他任何进程或服务执行的工作完成、错误或者其他状态变化时得到通知(通过监听状态事件)。当队列生产者或者消费者失败时,他们的状态会被保留,任务将在node重启后自动重启。

Nest提供了@nestjs/bull包,这是Bull包的一个包装器,Bull是一个流行的、支持良好的、高性能的基于Nodejs的消息队列系统应用。该包将Bull队列以Nest友好的方式添加到你的应用中。

Bull使用Redis持久化工作数据,因此你需要在你的系统中安装Redis。因为他是基于Redis的,你的队列结构可以是完全分布式的并且和平台无关。例如,你可以有一些队列生产者消费者监听者,他们运行在Nest的一个或多个节点上,同时,其他生产者、消费者和监听者在其他Node.js平台或者其他网络节点上。

本章使用@nestjs/bull包,我们同时推荐阅读BUll文档来获取更多背景和应用细节。

安装

要开始使用,我们首先安装需要的依赖:

  1. $ npm install --save @nestjs/bull bull
  2. $ npm install --save-dev @types/bull

一旦安装过程完成,我们可以在根AppModule中导入BullModule

app.module.ts

  1. import { Module } from '@nestjs/common';
  2. import { BullModule } from '@nestjs/bull';
  3. @Module({
  4. imports: [
  5. BullModule.registerQueue({
  6. name: 'audio',
  7. redis: {
  8. host: 'localhost',
  9. port: 6379,
  10. },
  11. }),
  12. ],
  13. })
  14. export class AppModule {}

registerQueue()方法用于实例化并/或注册队列。队列在不同的模块和进程之间共享,在底层则通过同样的凭据连接到同样的Redis数据库。每个队列由其name属性区分(如下),当共享队列(跨模块/进程)时,第一个registerQueue()方法同时实例化该队列并向模块注册它。其他模块(在相同或者不同进程下)则简单地注册队列。队列注册创建一个injection token,它可以被用在给定Nest模块中获取队列。

针对每个队列,传递一个包含下列属性的配置对象:

-name:string- 一个队列名称,它可以被用作injection token(用于将队列注册到控制器/提供者),也可以作为装饰器参数来将消费者类和监听者与队列联系起来。是必须的。 -limiter:RateLimiter-该选项用于确定消息队列处理速率,查看RateLimiter获取更多信息。可选的。 -redis:RedisOpts-该选项用于配置Redis连接,查看RedisOpts获取更多信息。可选的。 -prefix: string-队列所有键的前缀。可选的。 -defaultJobOptions: JobOpts-选项用以控制新任务的默认属性。查看JobOpts获取更多信息。可选的。 -settings: AdvancedSettings-高级队列配置设置。这些通常不需要改变。查看AdvancedSettings获取更多信息。可选的。

注意,name属性是必须的。其他选项是可选的,为队列行为提供更细节的控制。这些会直接传递给Bull的Queue构造器。在这里阅读更多选项。当在第二个或者子模块中注册一个队列时,最佳时间是省略配置对象中除name属性之外的所有选项。这些选项仅应该在实例化队列的模块中确定。

?> 在registerQueue()方法中传递多个逗号分隔的选项对象来创建多个队列。

由于任务在Redis中是持久化的,每次当一个特定名称的队列被实例化时(例如,当一个app启动/重启时),它尝试处理任何可能在前一个旧的任务遗留未完成的session

每个队里可能有一个或很多生产者、消费者以及监听者。消费者从一个特定命令队列中获取任务:FIFO(默认,先进先出),LIFO(后进先出)或者依据优先级。

控制队列处理命令在这里讨论。

生产者

任务生产者添加任务到队列中。生产者是典型的应用服务(Nest 提供者)。要添加工作到一个队列,首先注册队列到服务中:

  1. import { Injectable } from '@nestjs/common';
  2. import { Queue } from 'bull';
  3. import { InjectQueue } from '@nestjs/bull';
  4. @Injectable()
  5. export class AudioService {
  6. constructor(@InjectQueue('audio') private audioQueue: Queue) {}
  7. }

?> @InjectQueue()装饰器由其名称指定队列,像它在registerQueue()方法中提供的那样(例如,audio)。

现在,通过调用队列的add()方法添加一个任务,传递一个用户定义的任务对象。任务表现为序列化的JavaScript对象(因为它们被存储在Redis数据库中)。你传递的任务形式是可选的;用它来在语义上表示你任务对象:

  1. const job = await this.audioQueue.add({
  2. foo: 'bar',
  3. });

命名的任务

任务需要独一无二的名字。这允许你创建专用的消费者,这将仅处理给定名称的处理任务。

  1. const job = await this.audioQueue.add('transcode', {
  2. foo: 'bar',
  3. });

!> 当使用命名任务时,你必须为每个添加到队列中的特有名称创建处理者,否则队列会反馈缺失了给定任务的处理器。查看这里阅读更多关于消费命名任务的信息。

任务选项

任务可以包括附加选项。在Quene.add()方法的job参数之后传递选项对象。任务选项属性有:

  • priority: number-选项优先级值。范围从1(最高优先)到MAX_INT(最低优先)。注意使用属性对性能有轻微影响,因此要小心使用。
  • delay: number- 任务执行前等待的时间(毫秒)。注意,为了精确延时,服务端和客户端时钟应该同步。
  • attempts: number-任务结束前总的尝试次数。
  • repeat: RepeatOpts-按照定时设置重复任务记录,查看RepeatOpts
  • backoff: number | BackoffOpts- 如果任务失败,自动重试闪避设置,查看BackoffOpts
  • lifo: boolean-如果为true,从队列右端添加任务以替代从左边添加(默认为 false)。
  • timeout: number-任务超时失败的毫秒数。
  • jobId: number | string- 覆盖任务ID-默认地,任务ID是唯一的整数,但你可以使用该参数覆盖它。如果你使用这个选项,你需要保证jobId是唯一的。如果你尝试添加一个包含已有id的任务,它不会被添加。
  • removeOnComplete: boolean | number-如果为true,当任务完成时移除任务。一个数字用来指定要保存的任务数。默认行为是将完成的工作保存在已完成的设置中。
  • removeOnFail: boolean | number-如果为true,当所有尝试失败时移除任务。一个数字用来指定要保存的任务数。默认行为是将失败的任务保存在已失败的设置中。
  • stackTraceLimit: number-限制在stacktrace中保存的堆栈跟踪线。

这里是一些带有任务选项的自定义任务示例。

要延迟任务的开始,使用delay配置属性:

  1. const job = await this.audioQueue.add(
  2. {
  3. foo: 'bar',
  4. },
  5. { delay: 3000 }, // 3 seconds delayed
  6. );

要从右端添加任务到队列(以LIFO(后进先出)处理任务),设置配置对象的lifo属性为true

  1. const job = await this.audioQueue.add(
  2. {
  3. foo: 'bar',
  4. },
  5. { lifo: true },
  6. );

要优先一个任务,使用priority属性。

  1. const job = await this.audioQueue.add(
  2. {
  3. foo: 'bar',
  4. },
  5. { priority: 2 },
  6. );

消费者

消费者是一个类,定义的方法要么处理添加到队列中的任务,要么监听队列的事件,或者两者皆有。使用@Processor()装饰器来定义消费者类,如下:

  1. import { Processor } from '@nestjs/bull';
  2. @Processor('audio')
  3. export class AudioConsumer {}

装饰器的字符串参数(例如,audio)是和类方法关联的队列名称。

在消费者类中,使用@Process()装饰器来装饰任务处理者。

  1. import { Processor, Process } from '@nestjs/bull';
  2. import { Job } from 'bull';
  3. @Processor('audio')
  4. export class AudioConsumer {
  5. @Process()
  6. async transcode(job: Job<unknown>) {
  7. let progress = 0;
  8. for (i = 0; i < 100; i++) {
  9. await doSomething(job.data);
  10. progress += 10;
  11. job.progress(progress);
  12. }
  13. return {};
  14. }
  15. }

装饰器方法(例如transcode()) 在工作空闲或者队列中有消息要处理的时候被调用。该处理器方法接受job对象作为其仅有的参数。处理器方法的返回值被保存在任务对象中,可以在之后被访问,例如,在用于完成事件的监听者中。

Job对象有多个方法,允许你和他们的状态交互。例如,上述代码使用progress()方法来更新工作进程。查看这里以了解完整的Job对象API参照。

你可以指定一个任务处理方法,仅处理指定类型(包含特定name的任务)的任务,这可以通过如下所述的将name传递给@Process()装饰器完成。你在一个给定消费者类中可以有多个@Process()处理器,以反应每个任务类型(name),确保每个name有相应的处理者。

  1. @Process('transcode')
  2. async transcode(job: Job<unknown>) { ... }

事件监听者

当队列和/或任务状态改变时,Bull生成一个有用的事件集合。Nest提供了一个装饰器集合,允许订阅一系列标准核心事件集合。他们从@nestjs/bull包中导出。

事件监听者必须在一个消费者类中声明(通过@Processor()装饰器)。要监听一个事件,使用如下表格之一的装饰器来声明一个事件处理器。例如,当一个任务进入audio队列活跃状态时,要监听其发射的事件,使用下列结构:

  1. import { Processor, Process } from '@nestjs/bull';
  2. import { Job } from 'bull';
  3. @Processor('audio')
  4. export class AudioConsumer {
  5. @OnQueueActive()
  6. onActive(job: Job) {
  7. console.log(
  8. `Processing job ${job.id} of type ${job.name} with data ${job.data}...`,
  9. );
  10. }

鉴于BUll运行于分布式(多node)环境,它定义了本地事件概念。该概念可以辨识出一个由完整的单一进程触发的事件,或者由不同进程共享的队列。一个本地事件是指在本地进程中触发的一个队列行为或者状态变更。换句话说,当你的事件生产者和消费者是本地单进程时,队列中所有事件都是本地的。

当一个队列在多个进程中共享时,我们可能要遇到全局事件。对一个由其他进程触发的事件通知器进程的监听者来说,它必须注册为全局事件。

当相应事件发射时事件处理器被唤醒。该处理器被下表所示的签名调用,提供访问事件相关的信息。我们讨论下面签名中本地和全局事件处理器。

本地事件监听者 全局事件监听者 处理器方法签名/当触发时
@OnQueueError() @OnGlobalQueueError() handler(error: Error) - 当错误发生时,error包括触发错误
@OnQueueWaiting() @OnGlobalQueueWaiting() handler(jobId: number string)-一旦工作者空闲就等待执行的任务,jobId包括进入此状态的id
@OnQueueActive() @OnGlobalQueueActive() handler(job: Job)-job任务已启动
@OnQueueStalled() @OnGlobalQueueStalled() handler(job: Job)-job任务被标记为延迟。这在时间循环崩溃或暂停时进行调试工作时是很有效的
@OnQueueProgress() @OnGlobalQueueProgress() handler(job: Job, progress: number)-job任务进程被更新为progress
@OnQueueCompleted() @OnGlobalQueueCompleted() handler(job: Job, result: any) job任务进程成功以result结束
@OnQueueFailed() @OnGlobalQueueFailed() handler(job: Job, err: Error)job任务以err原因失败
@OnQueuePaused() @OnGlobalQueuePaused() handler()队列被暂停
@OnQueueResumed() @OnGlobalQueueResumed() handler(job: Job)队列被恢复
@OnQueueCleaned() @OnGlobalQueueCleaned() handler(jobs: Job[], type: string) 旧任务从队列中被清理,job是一个清理任务数组,type是要清理的任务类型
@OnQueueDrained() @OnGlobalQueueDrained() handler()在队列处理完所有等待的任务(除非有些尚未处理的任务被延迟)时发射出
@OnQueueRemoved() @OnGlobalQueueRemoved() handler(job: Job)job任务被成功移除

当监听全局事件时,签名方法可能和本地有一点不同。特别地,本地版本的任何方法签名接受job对象的方法签名而不是全局版本的jobId(number)。要在这种情况下获取实际的job对象的引用,使用Queue#getJob方法。这种调用可能需要等待,因此处理者应该被声明为async,例如:

  1. @OnGlobalQueueCompleted()
  2. async onGlobalCompleted(jobId: number, result: any) {
  3. const job = await this.immediateQueue.getJob(jobId);
  4. console.log('(Global) on completed: job ', job.id, ' -> result: ', result);
  5. }

?> 要获取一个Queue对象(使用getJob()调用),你当然必须注入它。同时,队列必须注册到你要注入的模块中。

在特定事件监听器装饰器之外,你可以使用通用的@OnQueueEvent()装饰器与BullQueueEvents或者BullQueueGlobalEvents枚举相结合。在这里阅读更多有关事件的内容。

队列管理

队列有一个API来实现管理功能比如暂停、恢复、检索不同状态的任务数量等。你可以在这里找到完整的队列API。直接在Queue对象上调用这些方法,如下所示的暂停/恢复示例。

使用pause()方法调用来暂停队列。一个暂停的队列在恢复前将不会处理新的任务,但会继续处理完当前执行的任务。

  1. await audioQueue.pause();

要恢复一个暂停的队列,使用resume()方法,如下:

  1. await audioQueue.resume();

异步配置

你可能需要异步而不是静态传递队列选项。在这种情况下,使用registerQueueAsync()方法,可以提供不同的异步配置方法。

一个方法是使用工厂函数:

  1. BullModule.registerQueueAsync({
  2. name: 'audio',
  3. useFactory: () => ({
  4. redis: {
  5. host: 'localhost',
  6. port: 6379,
  7. },
  8. }),
  9. });

我们的工厂函数方法和其他异步提供者(它可以是async的并可以使用inject来注入)方法相同。

  1. BullModule.registerQueueAsync({
  2. name: 'audio',
  3. imports: [ConfigModule],
  4. useFactory: async (configService: ConfigService) => ({
  5. redis: {
  6. host: configService.get('QUEUE_HOST'),
  7. port: +configService.get('QUEUE_PORT'),
  8. },
  9. }),
  10. inject: [ConfigService],
  11. });

可选的,你可以使用useClass语法。

  1. BullModule.registerQueueAsync({
  2. name: 'audio',
  3. useClass: BullConfigService,
  4. });

上述结构在BullModule中实例化BullConfigService,并通过调用createBullOptions()来用它提供一个选项对象。注意这意味着BullConfigService要实现BullOptionsFactory工厂接口,如下:

  1. @Injectable()
  2. class BullConfigService implements BullOptionsFactory {
  3. createBullOptions(): BullModuleOptions {
  4. return {
  5. redis: {
  6. host: 'localhost',
  7. port: 6379,
  8. },
  9. };
  10. }
  11. }

要阻止在BullModule中创建BullConfigService并使用一个从其他模块导入的提供者,可以使用useExisting语法。

  1. BullModule.registerQueueAsync({
  2. name: 'audio',
  3. imports: [ConfigModule],
  4. useExisting: ConfigService,
  5. });

这个结构和useClass有一个根本区别——BullModule将查找导入的模块来重用现有的ConfigServie而不是实例化一个新的。

示例

一个可用的示例见这里

日志

Nest附带一个默认的内部日志记录器实现,它在实例化过程中以及在一些不同的情况下使用,比如发生异常等等(例如系统记录)。这由@nestjs/common包中的Logger类实现。你可以全面控制如下的日志系统的行为:

  • 完全禁用日志
  • 指定日志系统详细水平(例如,展示错误,警告,调试信息等)
  • 完全覆盖默认日志记录器
  • 通过扩展自定义默认日志记录器
  • 使用依赖注入来简化编写和测试你的应用

你也可以使用内置日志记录器,或者创建你自己的应用来记录你自己应用水平的事件和消息。

更多高级的日志功能,可以使用任何Node.js日志包,比如Winston,来生成一个完全自定义的生产环境水平的日志系统。

基础自定义

要禁用日志,在(可选的)Nest应用选项对象中向NestFactory.create()传递第二个参数设置logger属性为false

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: false,
  3. });
  4. await app.listen(3000);

你也可以只启用特定日志级别,设置一个字符串形式的logger属性数组以确定要显示的日志水平,如下:

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: ['error', 'warn'],
  3. });
  4. await app.listen(3000);

数组中的字符串可以是以下字符串的任意组合:log,error,warn,debugverbose

自定义应用

你可以提供一个自定义日志记录器应用,并由Nest作为系统记录使用,这需要设置logger属性到一个满足LoggerService接口的对象。例如,你可以告诉Nest使用内置的全局JavaScriptconsole对象(其应用了LoggerService接口),如下:

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: console,
  3. });
  4. await app.listen(3000);

应用你的自定义记录器很简单。只要简单实现以下LoggerService接口中的每个方法就可以:

  1. import { LoggerService } from '@nestjs/common';
  2. export class MyLogger implements LoggerService {
  3. log(message: string) {
  4. /* your implementation */
  5. }
  6. error(message: string, trace: string) {
  7. /* your implementation */
  8. }
  9. warn(message: string) {
  10. /* your implementation */
  11. }
  12. debug(message: string) {
  13. /* your implementation */
  14. }
  15. verbose(message: string) {
  16. /* your implementation */
  17. }
  18. }

你可以通过logger属性为Nest应用的选项对象提供一个MyLogger实例:

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: new MyLogger(),
  3. });
  4. await app.listen(3000);

这个技术虽然很简单,但是没有为MyLogger类应用依赖注入。这会带来一些挑战,尤其在测试方面,同时也限制了MyLogger的重用性。更好的解决方案参见如下的依赖注入部分。

扩展内置的日志类

很多实例操作需要创建自己的日志。你不必完全重新发明轮子。只需扩展内置 Logger 类以部分覆盖默认实现,并使用 super 将调用委托给父类。

  1. import { Logger } from '@nestjs/common';
  2. export class MyLogger extends Logger {
  3. error(message: string, trace: string) {
  4. // add your tailored logic here
  5. super.error(message, trace);
  6. }
  7. }

你可以按如下使用应用记录器来记录部分所述,从你的特征模块中使用扩展记录器,也可以按照如下的依赖注入部分。如果你这样做,你在调用super时要小心,如上述代码示例,要委托一个特定的日志方法,调用其父(内置)类,以便Nest可以依赖需要的内置特征。

依赖注入

你可能需要利用依赖注入的优势来使用高级的日志记录功能。例如,你可能想把ConfigService注入到你的记录器中来对它自定义,然后把自定义记录器注入到其他控制器和/或提供者中。要为你的自定义记录器启用依赖注入,创建一个实现LoggerService的类并将其作为提供者注册在某些模块中,例如,你可以:

  1. 定义一个MyLogger类来扩展内置的Logger或者完全覆盖它,如前节所述。
  2. 创建一个LoggerModule如下所示,从该模块中提供MyLogger
  1. import { Module } from '@nestjs/common';
  2. import { MyLogger } from './my-logger.service.ts';
  3. @Module({
  4. providers: [MyLogger],
  5. exports: [MyLogger],
  6. })
  7. export class LoggerModule {}

通过这个结构,你现在可以提供你的自定义记录器供其他任何模块使用。因为你的MyLogger类是模块的一部分,它也可以使用依赖注入(例如,注入一个ConfigService)。提供自定义记录器供使用还需要一个技术,即Nest的系统记录(例如,供bootstrappingerror handling)。

由于应用实例化(NestFactory.create())在任何模块上下文之外发生,它不能参与初始化时正常的依赖注入阶段。因此我们必须保证至少一个应用模块导入了LoggerModule来触发Nest,从而生成一个我们的MyLogger类的单例。我们可以在之后按照下列知道来告诉Nest使用同一个MyLogger实例。

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: false,
  3. });
  4. app.useLogger(app.get(MyLogger));
  5. await app.listen(3000);

在这里我们在NestApplication实例中用了get()方法以获取MyLogger对象的单例。这个技术在根本上是一个“注入”logger实例供Nest使用的方法。app.get()调用获取MyLogger单例,并且像之前所述的那样依赖于第一个注入到其他模块的实例。

你也可以在你的特征类中注入这个MyLogger提供者,从而保证Nest系统记录和应用记录行为一致。参见如下为应用记录使用记录器部分。

为应用记录使用记录器

我们可以组合上述几种技术来提供一致性的行为和格式化以保证我们的应用事件/消息记录和Nest系统记录一致。在本部分,我们采用以下步骤:

  1. 我们扩展内置记录器并自定义记录消息的context部分(例如,如下的方括号中的NestFactory的形式)。
    1. [Nest] 19096 - 12/08/2019, 7:12:59 AM [NestFactory] Starting Nest application...
  2. 我们注入一个暂态的Logger实例在我们的特征模块中,从而使它们包含各自的自定义上下文。
  3. 我们提供扩展的记录器供Nest在系统记录中使用。

要开始,使用类似如下的内置记录器代码。我们提供scope选项作为一个Logger类的配置元数据,指定瞬态范围,以保证我们在每个特征模块中有独一无二的Logger的实例。例如,我们没有扩展每个单独的Logger方法(例如 log(),warn()等),尽管你可能选择要这样做。

  1. import { Injectable, Scope, Logger } from '@nestjs/common';
  2. @Injectable({ scope: Scope.TRANSIENT })
  3. export class MyLogger extends Logger {}

然后,我们采用如下结构创建一个LoggerModule

  1. import { Module } from '@nestjs/common';
  2. import { MyLogger } from './my-logger.service';
  3. @Module({
  4. providers: [MyLogger],
  5. exports: [MyLogger],
  6. })
  7. export class LoggerModule {}

接下来,在你的特征模块中导入LoggerModule,然后设置记录器上下文,并开始使用包含上下文的自定义记录器,如下:

  1. import { Injectable } from '@nestjs/common';
  2. import { MyLogger } from './my-logger.service';
  3. @Injectable()
  4. export class CatsService {
  5. private readonly cats: Cat[] = [];
  6. constructor(private myLogger: MyLogger) {
  7. this.myLogger.setContext('CatsService');
  8. }
  9. findAll(): Cat[] {
  10. this.myLogger.warn('About to return cats!');
  11. return this.cats;
  12. }
  13. }

最后,告诉Nest在你如下的main.ts文件中使用一个自定义记录器实例。当然,在本例中,我们没有实际自定义记录器行为(通过扩展Logger方法例如log()warn()等),因此该步骤并不是必须的。但如果你为这些方法添加了自定义逻辑,并且希望Nest使用它们时就应该这样做:

  1. const app = await NestFactory.create(ApplicationModule, {
  2. logger: false,
  3. });
  4. app.useLogger(new MyLogger());
  5. await app.listen(3000);

使用外部记录器

生产环境应用通常包括特定的记录需求,包括高级过滤器,格式化和中心化记录。Nest的内置记录器用于监控Nest系统状态,在开发时也可以为你的特征模块提供实用的基础的文本格式的记录,但生产环境可能更倾向于使用类似Winston的模块,这是一个标准的Node.js应用,你可以在Nest中体验到类似模块的优势。

文件上传

为了处理文件上传,Nest 提供了一个内置的基于multer中间件包的 Express模块。Multer 处理以 multipart/form-data 格式发送的数据,该格式主要用于通过 HTTP POST 请求上传文件。这个模块是完全可配置的,您可以根据您的应用程序需求调整它的行为。

!> Multer无法处理不是受支持的多部分格式(multipart/form-data)的数据。 另外,请注意此程序包与 FastifyAdapter不兼容。

基本实例

当我们要上传单个文件时, 我们只需将 FileInterceptor() 与处理程序绑定在一起, 然后使用 @UploadedFile() 装饰器从 request 中取出 file

  1. @Post('upload')
  2. @UseInterceptors(FileInterceptor('file'))
  3. uploadFile(@UploadedFile() file) {
  4. console.log(file);
  5. }

?> FileInterceptor() 装饰器是 @nestjs/platform-express 包提供的, @UploadedFile() 装饰是 @nestjs/common 包提供的。

FileInterceptor() 接收两个参数:

  • 一个 fieldName (指向包含文件的 HTML 表单的字段)

  • 可选 options 对象。这些 MulterOptions 等效于传入 multer 构造函数 (此处有更多详细信息)

文件数组

为了上传文件数组,我们使用 FilesInterceptor()。请使用 FilesInterceptor() 装饰器(注意装饰器名称中的复数文件)。这个装饰器有三个参数:

  • fieldName:(保持不变)

  • maxCount:可选的数字,定义要接受的最大文件数

  • options:可选的 MulterOptions 对象 ,如上所述

使用 FilesInterceptor() 时,使用 @UploadedFiles() 装饰器从请求中提取文件。

  1. @Post('upload')
  2. @UseInterceptors(FilesInterceptor('files'))
  3. uploadFile(@UploadedFiles() files) {
  4. console.log(files);
  5. }

?> FilesInterceptor() 装饰器需要导入 @nestjs/platform-express,而 @UploadedFiles() 导入 @nestjs/common

多个文件

要上传多个文件(全部使用不同的键),请使用 FileFieldsInterceptor() 装饰器。这个装饰器有两个参数:

  • uploadedFields:对象数组,其中每个对象指定一个必需的 name 属性和一个指定字段名的字符串值(如上所述),以及一个可选的 maxCount 属性(如上所述)

  • options:可选的 MulterOptions 对象,如上所述

使用 FileFieldsInterceptor() 时,使用 @UploadedFiles() 装饰器从 request 中提取文件。

  1. @Post('upload')
  2. @UseInterceptors(FileFieldsInterceptor([
  3. { name: 'avatar', maxCount: 1 },
  4. { name: 'background', maxCount: 1 },
  5. ]))
  6. uploadFile(@UploadedFiles() files) {
  7. console.log(files);
  8. }

任何文件

要使用任意字段名称键上载所有字段,请使用 AnyFilesInterceptor() 装饰器。该装饰器可以接受如上所述的可选选项对象。

使用 FileFieldsInterceptor() 时,使用 @UploadedFiles() 装饰器从 request 中提取文件。

  1. @Post('upload')
  2. @UseInterceptors(AnyFilesInterceptor())
  3. uploadFile(@UploadedFiles() files) {
  4. console.log(files);
  5. }

默认选项

您可以像上面描述的那样在文件拦截器中指定 multer 选项。要设置默认选项,可以在导入 MulterModule 时调用静态 register() 方法,传入受支持的选项。您可以使用这里列出的所有选项。

  1. MulterModule.register({
  2. dest: '/upload',
  3. });

?> MulterModule类从@nestjs/platform-express包中导出

异步配置

当需要异步而不是静态地设置 MulterModule 选项时,请使用 registerAsync() 方法。与大多数动态模块一样,Nest 提供了一些处理异步配置的技术。

第一种可能的方法是使用工厂函数:

  1. MulterModule.registerAsync({
  2. useFactory: () => ({
  3. dest: '/upload',
  4. }),
  5. });

与其他工厂提供程序一样,我们的工厂函数可以是异步的,并且可以通过注入注入依赖。

  1. MulterModule.registerAsync({
  2. imports: [ConfigModule],
  3. useFactory: async (configService: ConfigService) => ({
  4. dest: configService.getString('MULTER_DEST'),
  5. }),
  6. inject: [ConfigService],
  7. });

或者,您可以使用类而不是工厂来配置 MulterModule,如下所示:

  1. MulterModule.registerAsync({
  2. useClass: MulterConfigService,
  3. });

上面的构造在 MulterModule 中实例化 MulterConfigService ,使用它来创建所需的 options 对象。注意,在本例中,MulterConfigService 必须实现 MulterOptionsFactory 接口,如下所示。MulterModule 将在提供的类的实例化对象上调用 createMulterOptions() 方法。

  1. @Injectable()
  2. class MulterConfigService implements MulterOptionsFactory {
  3. createMulterOptions(): MulterModuleOptions {
  4. return {
  5. dest: '/upload',
  6. };
  7. }
  8. }

为了防止创建 MulterConfigService 内部 MulterModule 并使用从不同模块导入的提供程序,您可以使用 useExisting 语法。

  1. MulterModule.registerAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

HTTP 模块

Axios 是丰富功能的 HTTP 客户端, 广泛应用于许多应用程序中。这就是为什么 Nest 包装这个包, 并公开它默认为内置 HttpModuleHttpModule 导出 HttpService, 它只是公开了基于 axios 的方法来执行 HTTP 请求, 而且还将返回类型转换为 Observables

为了使用 httpservice,我们需要导入 HttpModule

  1. @Module({
  2. imports: [HttpModule],
  3. providers: [CatsService],
  4. })
  5. export class CatsModule {}

?> HttpModule@nestjs/common 包提供的

然后,你可以注入 HttpService。这个类可以从@nestjs/common 包中获取。

  1. @Injectable()
  2. export class CatsService {
  3. constructor(private readonly httpService: HttpService) {}
  4. findAll(): Observable<AxiosResponse<Cat[]>> {
  5. return this.httpService.get('http://localhost:3000/cats');
  6. }
  7. }

所有方法都返回 AxiosResponse, 并使用 Observable 对象包装。

配置

Axios 提供了许多选项,您可以利用这些选项来增加您的 HttpService 功能。在这里阅读更多相关信息。要配置底层库实例,请使用 register() 方法的 HttpModule。所有这些属性都将传递给 axios 构造函数。

  1. @Module({
  2. imports: [
  3. HttpModule.register({
  4. timeout: 5000,
  5. maxRedirects: 5,
  6. }),
  7. ],
  8. providers: [CatsService],
  9. })
  10. export class CatsModule {}

异步配置

通常,您可能希望异步传递模块属性,而不是事先传递它们。在这种情况下,使用 registerAsync() 方法,提供了几种处理异步数据的方法。

第一种可能的方法是使用工厂函数:

  1. HttpModule.registerAsync({
  2. useFactory: () => ({
  3. timeout: 5000,
  4. maxRedirects: 5,
  5. }),
  6. });

显然,我们的工厂表现得与其他工厂一样( async 能够通过 inject 注入依赖关系)。

  1. HttpModule.registerAsync({
  2. imports: [ConfigModule],
  3. useFactory: async (configService: ConfigService) => ({
  4. timeout: configService.getString('HTTP_TIMEOUT'),
  5. maxRedirects: configService.getString('HTTP_MAX_REDIRECTS'),
  6. }),
  7. inject: [ConfigService],
  8. });

或者,您可以使用类而不是工厂。

  1. HttpModule.registerAsync({
  2. useClass: HttpConfigService,
  3. });

上面的构造将在 HttpModule 中实例化 HttpConfigService,并利用它来创建 options 对象。 HttpConfigService 必须实现 HttpModuleOptionsFactory 接口。

  1. @Injectable()
  2. class HttpConfigService implements HttpModuleOptionsFactory {
  3. createHttpOptions(): HttpModuleOptions {
  4. return {
  5. timeout: 5000,
  6. maxRedirects: 5,
  7. };
  8. }
  9. }

为了防止在 HttpModule 中创建 HttpConfigService 并使用从不同模块导入的提供者,您可以使用 useExisting 语法。

  1. HttpModule.registerAsync({
  2. imports: [ConfigModule],
  3. useExisting: ConfigService,
  4. });

它的工作原理与 useClass 相同,但有一个关键的区别: HttpModule 将查找导入的模块来重用已经创建的 ConfigService,而不是自己实例化它。

MVC

Nest 默认使用 Express 库,因此有关Express 中的 MVC(模型 - 视图 - 控制器)模式的每个教程都与 Nest 相关。首先,让我们使用 CLI 工具搭建一个简单的 Nest 应用程序:

  1. $ npm i -g @nestjs/cli
  2. $ nest new project

为了创建一个简单的 MVC 应用程序,我们必须安装一个模板引擎

  1. $ npm install --save hbs

我们决定使用 hbs 引擎,但您可以使用任何符合您要求的内容。安装过程完成后,我们需要使用以下代码配置 express 实例:

main.ts

  1. import { NestFactory } from '@nestjs/core';
  2. import { NestExpressApplication } from '@nestjs/platform-express';
  3. import { join } from 'path';
  4. import { AppModule } from './app.module';
  5. async function bootstrap() {
  6. const app = await NestFactory.create<NestExpressApplication>(
  7. AppModule,
  8. );
  9. app.useStaticAssets(join(__dirname, '..', 'public'));
  10. app.setBaseViewsDir(join(__dirname, '..', 'views'));
  11. app.setViewEngine('hbs');
  12. await app.listen(3000);
  13. }
  14. bootstrap();

我们告诉 express,该 public 目录将用于存储静态文件, views 将包含模板,并且 hbs 应使用模板引擎来呈现 HTML 输出。

模板渲染

现在,让我们在该文件夹内创建一个 views 目录和一个 index.hbs 模板。在模板内部,我们将打印从控制器传递的 message

index.hbs

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8" />
  5. <title>App</title>
  6. </head>
  7. <body>
  8. {{ message }}
  9. </body>
  10. </html>

然后, 打开 app.controller 文件, 并用以下代码替换 root() 方法:

app.controller.ts

  1. import { Get, Controller, Render } from '@nestjs/common';
  2. @Controller()
  3. export class AppController {
  4. @Get()
  5. @Render('index')
  6. root() {
  7. return { message: 'Hello world!' };
  8. }
  9. }

在这个代码中,我们指定模板使用@Render()装饰器,同时将路径处理器方法的返回值被传递给要渲染的模板。注意,该返回值是一个包含message属性的对象,和我们之前创建模板中的message占位符对应。

在应用程序运行时,打开浏览器访问 http://localhost:3000/ 你应该看到这个 Hello world! 消息。

动态模板渲染

如果应用程序逻辑必须动态决定要呈现哪个模板,那么我们应该使用 @Res()装饰器,并在路由处理程序中提供视图名,而不是在 @Render() 装饰器中:

?> 当 Nest 检测到 @Res() 装饰器时,它将注入特定于库的响应对象。我们可以使用这个对象来动态呈现模板。在这里了解关于响应对象 API 的更多信息。

app.controller.ts

  1. import { Get, Controller, Render } from '@nestjs/common';
  2. import { Response } from 'express';
  3. import { AppService } from './app.service';
  4. @Controller()
  5. export class AppController {
  6. constructor(private readonly appService: AppService) {}
  7. @Get()
  8. root(@Res() res: Response) {
  9. return res.render(
  10. this.appService.getViewName(),
  11. { message: 'Hello world!' },
  12. );
  13. }
  14. }

这里有一个可用的例子。

Fastify

如本章所述,我们可以将任何兼容的 HTTP 提供程序与 Nest 一起使用。比如 Fastify 。为了创建具有 fastifyMVC 应用程序,我们必须安装以下包:

  1. $ npm i --save fastify point-of-view handlebars

接下来的步骤几乎涵盖了与 express 库相同的内容(差别很小)。安装过程完成后,我们需要打开 main.ts 文件并更新其内容:

main.ts

  1. import { NestFactory } from '@nestjs/core';
  2. import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
  3. import { AppModule } from './app.module';
  4. import { join } from 'path';
  5. async function bootstrap() {
  6. const app = await NestFactory.create<NestFastifyApplication>(
  7. AppModule,
  8. new FastifyAdapter(),
  9. );
  10. app.useStaticAssets({
  11. root: join(__dirname, '..', 'public'),
  12. prefix: '/public/',
  13. });
  14. app.setViewEngine({
  15. engine: {
  16. handlebars: require('handlebars'),
  17. },
  18. templates: join(__dirname, '..', 'views'),
  19. });
  20. await app.listen(3000);
  21. }
  22. bootstrap();

Fastify的API 略有不同,但这些方法调用背后的想法保持不变。使用Fastify时一个明显的需要注意的区别是传递到 @Render() 装饰器中的模板名称包含文件扩展名。

app.controller.ts

  1. import { Get, Controller, Render } from '@nestjs/common';
  2. @Controller()
  3. export class AppController {
  4. @Get()
  5. @Render('index.hbs')
  6. root() {
  7. return { message: 'Hello world!' };
  8. }
  9. }

在应用程序运行时,打开浏览器并导航至 http://localhost:3000/ 。你应该看到这个 Hello world! 消息。

这里有一个可用的例子。

性能(Fastify)

在底层,Nest 使用了Express框架,但如前所述,它提供了与各种其他库的兼容性,例如 FastifyNest应用一个框架适配器,其主要功能是代理中间件和处理器到适当的特定库应用中,从而达到框架的独立性。

?> 注意要应用框架适配器,目标库必须提供在Express 类似的请求/响应管道处理

Fastify 非常适合这里,因为它以与 express 类似的方式解决设计问题。然而,fastify 的速度要快得多,达到了几乎两倍的基准测试结果。问题是,为什么 Nest 仍然使用 express 作为默认的HTTP提供程序?因为 express 是应用广泛、广为人知的,而且拥有一套庞大的兼容中间件。

但是由于 Nest 提供了框架独立性,因此您可以轻松地在它们之间迁移。当您对快速的性能给予很高的评价时,Fastify 可能是更好的选择。要使用 Fastify,只需选择 FastifyAdapter本章所示的内置功能。

安装

首先,我们需要安装所需的软件包:

  1. $ npm i --save @nestjs/platform-fastify

适配器(Adapter)

安装fastify后,我们可以使用 FastifyAdapter

  1. import { NestFactory } from '@nestjs/core';
  2. import {
  3. FastifyAdapter,
  4. NestFastifyApplication,
  5. } from '@nestjs/platform-fastify';
  6. import { ApplicationModule } from './app.module';
  7. async function bootstrap() {
  8. const app = await NestFactory.create<NestFastifyApplication>(
  9. ApplicationModule,
  10. new FastifyAdapter()
  11. );
  12. await app.listen(3000);
  13. }
  14. bootstrap();

默认情况下,Fastify仅在 localhost 127.0.0.1 接口上监听(了解更多信息)。如果要接受其他主机上的连接,则应'0.0.0.0'listen() 呼叫中指定:

  1. async function bootstrap() {
  2. const app = await NestFactory.create<NestFastifyApplication>(
  3. ApplicationModule,
  4. new FastifyAdapter()
  5. );
  6. await app.listen(3000, '0.0.0.0');
  7. }

平台特定的软件包

请记住,当您使用 FastifyAdapter 时,Nest 使用 Fastify 作为 HTTP 提供程序。 这意味着依赖 Express 的每个配方都可能不再起作用。 您应该改为使用 Fastify 等效程序包。

重定向响应

Fastify 处理重定向响应的方式与 Express 有所不同。要使用 Fastify 进行正确的重定向,请同时返回状态代码和 URL,如下所示:

  1. @Get()
  2. index(@Res() res) {
  3. res.status(302).redirect('/login');
  4. }

Fastify 选项

您可以通过构造函数将选项传递给 Fastify的构造 FastifyAdapter 函数。例如:

  1. new FastifyAdapter({ logger: true })

例子

这里有一个工作示例

译者署名

用户名 头像 职能 签名
@zuohuadong 技术 - 图1 翻译 专注于 caddy 和 nest,@zuohuadong at Github
@Drixn 技术 - 图2 翻译 专注于 nginx 和 C++,@Drixn

@Armor | 技术 - 图3 | 翻译 | 专注于 Java 和 Nest,@Armor | | @Erchoc | 技术 - 图4 | 翻译 | 学习更优雅的架构方式,做更贴切用户的产品。@Erchoc at Github | | @havef | 技术 - 图5 | 校正 | 数据分析、机器学习、TS/JS技术栈 @havef | | @weizy0219 | 技术 - 图6 | 翻译 | 专注于TypeScript全栈、物联网和Python数据科学,@weizhiyong |