NestJS 学习 —— 基本概念
00 分钟
2023-4-8
2023-4-12
type
status
date
slug
summary
tags
category
icon
password
Edited
Apr 12, 2023 02:03 PM
Created
Apr 12, 2023 01:52 PM

概念

用几张图了解一下nest整体架构
notion image
notion image
notion image
notion image
notion image

控制器 Controller

控制器负责处理传入的请求和向客户端返回响应
notion image
控制器的目的是接收应用的特定请求。路由机制控制哪个控制器接收哪些请求。通常,每个控制器有多个路由,不同的路由可以执行不同的操作。
为了创建一个基本的控制器,我们使用类和装饰器。装饰器将类与所需的元数据相关联,并使 Nest 能够创建路由映射(将请求绑定到相应的控制器)。

Nest 提供的装饰器

@Request(),@Req()
req
@Response(),@Res()*
res
@Next()
next
@Session()
req.session
@Param(key?: string)
req.params/req.params[key]
@Body(key?: string)
req.body/req.body[key]
@Query(key?: string)
req.query/req.query[key]
@Headers(name?: string)
req.headers/req.headers[name]
@Ip()
req.ip
@HostParam()
req.hosts

路由通配符

路由同样支持模式匹配。例如,星号被用作通配符,将匹配任何字符组合。
路由路径 'ab*cd' 将匹配 abcd 、ab_cd 、abecd 等。字符 ? 、+ 、 * 以及 () 是它们的正则表达式对应项的子集。连字符(-) 和点(.)按字符串路径逐字解析。

状态码

如上所述,默认情况下,响应的状态码总是默认为 200,除了 POST 请求(默认响应状态码为 201),我们可以通过在处理函数外添加 @HttpCode(...) 装饰器来轻松更改此行为。
HttpCode 需要从 @nestjs/common 包导入。
通常,状态码不是固定的,而是取决于各种因素。在这种情况下,您可以使用类库特有(library-specific)的 response (通过 @Res()注入 )对象(或者在出现错误时,抛出异常)。

Headers

要指定自定义响应头,可以使用 @header() 装饰器或类库特有的响应对象,(并直接调用 res.header())。
Header 需要从 @nestjs/common 包导入。

重定向

要将响应重定向到特定的 URL,可以使用 @Redirect() 装饰器或特定于库的响应对象(并直接调用 res.redirect())。
@Redirect() 装饰器有两个可选参数,url 和 statusCode。 如果省略,则 statusCode 默认为 302
有时您可能想动态地决定 HTTP 状态代码或重定向 URL。通过从路由处理方法返回一个如下格式的对象:
返回的值将覆盖传递给 @Redirect()装饰器的所有参数。 例如:

路由参数

当您需要接受动态数据(dynamic data)作为请求的一部分时(例如,使用GET /cats/1 来获取 id 为 1 的 cat),带有静态路径的路由将无法工作。为了定义带参数的路由,我们可以在路由路径中添加路由参数标记(token)以捕获请求 URL 中该位置的动态值。下面的 @Get() 装饰器示例中的路由参数标记(route parameter token)演示了此用法。以这种方式声明的路由参数可以使用 @Param() 装饰器访问,该装饰器应添加到函数签名中。
@Param() 用于修饰一个方法的参数(上面示例中的 params),并在该方法内将路由参数作为被修饰的方法参数的属性。如上面的代码所示,我们可以通过引用 params.id来访问(路由路径中的) id 参数。 您还可以将特定的参数标记传递给装饰器,然后在方法主体中按参数名称直接引用路由参数。
Param 需要从 @nestjs/common 包导入。

子域路由

@Controller 装饰器可以接受一个 host 选项,以要求传入请求的 HTTP 主机匹配某个特定值。
 

提供者 Provider

Providers 是 Nest 的一个基本概念。许多基本的 Nest 类可能被视为 provider - service repositoryfactoryhelper 等等。 他们都可以通过 constructor 注入依赖关系。 这意味着对象可以彼此创建各种关系,并且“连接”对象实例的功能在很大程度上可以委托给 Nest运行时系统。 Provider 只是一个用 @Injectable() 装饰器注释的类。
notion image
控制器应处理 HTTP 请求并将更复杂的任务委托给 providersProviders 是纯粹的 JavaScript 类,在其类声明之前带有 @Injectable()装饰器。

依赖注入

Nest 是建立在强大的设计模式,通常称为依赖注入。我们建议在官方的 Angular文档中阅读有关此概念的精彩文章。
在 Nest 中,借助 TypeScript 功能,管理依赖项非常容易,因为它们仅按类型进行解析。在下面的示例中,Nest 将 catsService 通过创建并返回一个实例来解析 CatsService(或者,在单例的正常情况下,如果现有实例已在其他地方请求,则返回现有实例)。解析此依赖关系并将其传递给控制器的构造函数(或分配给指定的属性):

注册提供者

现在我们已经定义了提供者(CatsService),并且已经有了该服务的使用者(CatsController),我们需要在 Nest 中注册该服务,以便它可以执行注入。 为此,我们可以编辑模块文件(app.module.ts),然后将服务添加到@Module()装饰器的 providers 数组中。
app.module.ts
 

模块 Module

模块是具有 @Module() 装饰器的类。 @Module() 装饰器提供了元数据,Nest 用它来组织应用程序结构。
notion image
每个 Nest 应用程序至少有一个模块,即根模块。根模块是 Nest 开始安排应用程序树的地方。事实上,根模块可能是应用程序中唯一的模块,特别是当应用程序很小时,但是对于大型程序来说这是没有意义的。在大多数情况下,您将拥有多个模块,每个模块都有一组紧密相关的功能
@module() 装饰器接受一个描述模块属性的对象:
providers
由 Nest 注入器实例化的提供者,并且可以至少在整个模块中共享
controllers
必须创建的一组控制器
imports
导入模块的列表,这些模块导出了此模块中所需提供者
exports
由本模块提供并应在其他模块中可用的提供者的子集。
默认情况下,该模块封装提供程序。这意味着无法注入既不是当前模块的直接组成部分,也不是从导入的模块导出的提供程序。因此,您可以将从模块导出的提供程序视为模块的公共接口或API。

共享模块

在 Nest 中,默认情况下,模块是单例,因此您可以轻松地在多个模块之间共享同一个提供者实例。
notion image
实际上,每个模块都是一个共享模块。一旦创建就能被任意模块重复使用。假设我们将在几个模块之间共享 CatsService 实例。 我们需要把 CatsService 放到 exports 数组中,如下所示:
cats.module.ts
现在,每个导入 CatsModule 的模块都可以访问 CatsService ,并且它们将共享相同的 CatsService 实例。

模块导出

模块可以导出他们的内部提供者。 而且,他们可以再导出自己导入的模块。

全局模块

如果你不得不在任何地方导入相同的模块,那可能很烦人。在 Angular 中,提供者是在全局范围内注册的。一旦定义,他们到处可用。另一方面,Nest 将提供者封装在模块范围内。您无法在其他地方使用模块的提供者而不导入他们。但是有时候,你可能只想提供一组随时可用的东西 - 例如:helper,数据库连接等等。这就是为什么你能够使模块成为全局模块。
@Global 装饰器使模块成为全局作用域。 全局模块应该只注册一次,最好由根或核心模块注册。 在上面的例子中,CatsService 组件将无处不在,而想要使用 CatsService 的模块则不需要在 imports 数组中导入 CatsModule
ℹ️
使一切全局化并不是一个好的解决方案。 全局模块可用于减少必要模板文件的数量。 imports 数组仍然是使模块 API 透明的最佳方式。

动态模块

Nest 模块系统包括一个称为动态模块的强大功能。此功能使您可以轻松创建可自定义的模块,这些模块可以动态注册和配置提供程序。动态模块在这里广泛介绍。在本章中,我们将简要概述以完成模块介绍。
以下是一个动态模块定义的示例 DatabaseModule
forRoot() 可以同步或异步(Promise)返回动态模块。
此模块 Connection 默认情况下(在 @Module() 装饰器元数据中)定义提供程序,但此外-根据传递给方法的 entities 和 options 对象 forRoot() -公开提供程序的集合,例如存储库。请注意,动态模块返回的属性扩展(而不是覆盖)@Module() 装饰器中定义的基本模块元数据。这就是从模块导出静态声明的 Connection 提供程序和动态生成的存储库提供程序的方式。
如果要在全局范围内注册动态模块,请将 global 属性设置为 true
如上所述,将所有内容全局化不是一个好的设计决策。
所述 DatabaseModule 可以被导入,并且被配置以下列方式:
如果要依次重新导出动态模块,则可以 forRoot() 在导出数组中省略方法调用:
 

中间件 Middleware

中间件是在路由处理程序 之前 调用的函数。 中间件函数可以访问请求和响应对象,以及应用程序请求响应周期中的 next() 中间件函数。 next() 中间件函数通常由名为 next 的变量表示。
notion image
Nest 中间件实际上等价于 express 中间件。 下面是Express官方文档中所述的中间件功能:
中间件函数可以执行以下任务:
  • 执行任何代码。
  • 对请求和响应对象进行更改。
  • 结束请求-响应周期。
  • 调用堆栈中的下一个中间件函数。
  • 如果当前的中间件函数没有结束请求-响应周期, 它必须调用 next() 将控制传递给下一个中间件函数。否则, 请求将被挂起。
您可以在函数中或在具有 @Injectable() 装饰器的类中实现自定义 Nest中间件。 这个类应该实现 NestMiddleware 接口, 而函数没有任何特殊的要求。 让我们首先使用类方法实现一个简单的中间件功能。
logger.middleware.ts

应用中间件

中间件不能在 @Module() 装饰器中列出。我们必须使用模块类的 configure() 方法来设置它们。包含中间件的模块必须实现 NestModule 接口。我们将 LoggerMiddleware 设置在 ApplicationModule 层上。
app.module.ts
我们还可以在配置中间件时将包含路由路径的对象和请求方法传递给forRoutes()方法。我们为之前在CatsController中定义的/cats路由处理程序设置了LoggerMiddleware。我们还可以在配置中间件时将包含路由路径的对象和请求方法传递给 forRoutes()方法,从而进一步将中间件限制为特定的请求方法。在下面的示例中,请注意我们导入了 RequestMethod来引用所需的请求方法类型。
app.module.ts
可以使用 async/await来实现 configure()方法的异步化(例如,可以在 configure()方法体中等待异步操作的完成)。

路由通配符

路由同样支持模式匹配。例如,星号被用作通配符,将匹配任何字符组合。
以上路由地址将匹配 abcd 、 ab_cd 、 abecd 等。字符 ? 、 + 、 * 以及 () 是它们的正则表达式对应项的子集。连字符 (-) 和点 (.) 按字符串路径解析。
该 fastify 软件包使用该软件包的最新版本,该版本 path-to-regexp 不再支持通配符星号*。相反,您必须使用参数(例如(.*),:splat*)。

中间件消费者

MiddlewareConsumer 是一个帮助类。它提供了几种内置方法来管理中间件。他们都可以被简单地链接起来。forRoutes() 可接受一个字符串、多个字符串、对象、一个控制器类甚至多个控制器类。在大多数情况下,您可能只会传递一个由逗号分隔的控制器列表。以下是单个控制器的示例:
app.module.ts
该 apply() 方法可以使用单个中间件,也可以使用多个参数来指定多个多个中间件
有时我们想从应用中间件中排除某些路由。我们可以使用该 exclude() 方法轻松排除某些路由。此方法可以采用一个字符串,多个字符串或一个 RouteInfo 对象来标识要排除的路由,如下所示:
该 exclude() 方法使用 path-to-regexp 包支持通配符参数。
在上面的示例中,LoggerMiddleware 将绑定到内部定义的所有路由,CatsController 但传递给 exclude() 方法的三个路由除外。

函数式中间件

我们使用的 LoggerMiddleware 类非常简单。它没有成员,没有额外的方法,没有依赖关系。为什么我们不能只使用一个简单的函数?这是一个很好的问题,因为事实上 - 我们可以做到。这种类型的中间件称为函数式中间件。让我们把 logger 转换成函数。
logger.middleware.ts
现在在 AppModule 中使用它。
app.module.ts
当您的中间件没有任何依赖关系时,我们可以考虑使用函数式中间件。

多个中间件

如前所述,为了绑定顺序执行的多个中间件,我们可以在 apply() 方法内用逗号分隔它们。

全局中间件

如果我们想一次性将中间件绑定到每个注册路由,我们可以使用由INestApplication实例提供的 use()方法:
 

异常过滤器 Exception & Filter

内置的异常层负责处理整个应用程序中的所有抛出的异常。当捕获到未处理的异常时,最终用户将收到友好的响应。
notion image
开箱即用,此操作由内置的全局异常过滤器执行,该过滤器处理类型 HttpException(及其子类)的异常。每个发生的异常都由全局异常过滤器处理, 当这个异常无法被识别时 (既不是 HttpException 也不是继承的类 HttpException )

基础异常类

Nest提供了一个内置的 HttpException 类,它从 @nestjs/common 包中导入。对于典型的基于HTTP REST/GraphQL API的应用程序,最佳实践是在发生某些错误情况时发送标准HTTP响应对象。
在 CatsController,我们有一个 findAll() 方法(GET 路由)。假设此路由处理程序由于某种原因引发异常。 为了说明这一点,我们将对其进行如下硬编码:
cats.controller.ts
我们在这里使用了 HttpStatus 。它是从 @nestjs/common 包导入的辅助枚举器。
现在当客户端调用这个端点时,响应如下所示:
HttpException 构造函数有两个必要的参数来决定响应:
  • response 参数定义 JSON 响应体。它可以是 string 或 object,如下所述。
默认情况下,JSON 响应主体包含两个属性:
  • statusCode:默认为 status 参数中提供的 HTTP 状态代码
  • message:基于状态的 HTTP 错误的简短描述
仅覆盖 JSON 响应主体的消息部分,请在 response参数中提供一个 string
要覆盖整个 JSON 响应主体,请在response 参数中传递一个object。 Nest将序列化对象,并将其作为JSON 响应返回。
第二个构造函数参数-status-是有效的 HTTP 状态代码。 最佳实践是使用从@nestjs/common导入的 HttpStatus枚举。
这是一个覆盖整个响应正文的示例:
cats.controller.ts
使用上面的代码,响应如下所示:

所有基本异常类

为了减少样板代码,Nest 提供了一系列继承自核心异常 HttpException 的可用异常。所有这些都可以在 @nestjs/common包中找到:
  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • HttpVersionNotSupportedException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableEntityException
  • InternalServerErrorException
  • NotImplementedException
  • ImATeapotException
  • MethodNotAllowedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException
  • PreconditionFailedException

自定义异常

在许多情况下,您无需编写自定义异常,而可以使用内置的 Nest HTTP异常,如下一节所述。 如果确实需要创建自定义的异常,则最好创建自己的异常层次结构,其中自定义异常继承自 HttpException 基类。 使用这种方法,Nest可以识别您的异常,并自动处理错误响应。 让我们实现这样一个自定义异常:
forbidden.exception.ts
由于 ForbiddenException 扩展了基础 HttpException,它将和核心异常处理程序一起工作,因此我们可以在 findAll()方法中使用它。
cats.controller.ts

异常过滤器

虽然基本(内置)异常过滤器可以为您自动处理许多情况,但有时您可能希望对异常层拥有完全控制权,例如,您可能希望基于某些动态因素添加日志记录或使用不同的 JSON 模式。 异常过滤器正是为此目的而设计的。 它们使您可以控制精确的控制流以及将响应的内容发送回客户端。
让我们创建一个异常过滤器,它负责捕获作为HttpException类实例的异常,并为它们设置自定义响应逻辑。为此,我们需要访问底层平台 Request和 Response。我们将访问Request对象,以便提取原始 url并将其包含在日志信息中。我们将使用 Response.json()方法,使用 Response对象直接控制发送的响应。
http-exception.filter.ts
所有异常过滤器都应该实现通用的 ExceptionFilter<T> 接口。它需要你使用有效签名提供 catch(exception: T, host: ArgumentsHost)方法。T 表示异常的类型。
@Catch() 装饰器绑定所需的元数据到异常过滤器上。它告诉 Nest这个特定的过滤器正在寻找 HttpException 而不是其他的。在实践中,@Catch() 可以传递多个参数,所以你可以通过逗号分隔来为多个类型的异常设置过滤器。

绑定过滤器

让我们将 HttpExceptionFilter 绑定到 CatsController 的 create() 方法上。
cats.controller.ts
@UseFilters() 装饰器需要从 @nestjs/common 包导入。
我们在这里使用了 @UseFilters() 装饰器。和 @Catch()装饰器类似,它可以使用单个过滤器实例,也可以使用逗号分隔的过滤器实例列表。 我们创建了 HttpExceptionFilter 的实例。另一种可用的方式是传递类(不是实例),让框架承担实例化责任并启用依赖注入。
cats.controller.ts
尽可能使用类而不是实例。由于 Nest 可以轻松地在整个模块中重复使用同一类的实例,因此可以减少内存使用
在上面的示例中,HttpExceptionFilter 仅应用于单个 create() 路由处理程序,使其成为方法范围的。 异常过滤器的作用域可以划分为不同的级别:方法范围,控制器范围或全局范围。 例如,要将过滤器设置为控制器作用域,您可以执行以下操作:
cats.controller.ts
此结构为 CatsController 中的每个路由处理程序设置 HttpExceptionFilter
要创建一个全局范围的过滤器,您需要执行以下操作:
main.ts
该 useGlobalFilters() 方法不会为网关和混合应用程序设置过滤器。
全局过滤器用于整个应用程序、每个控制器和每个路由处理程序。就依赖注入而言,从任何模块外部注册的全局过滤器(使用上面示例中的 useGlobalFilters())不能注入依赖,因为它们不属于任何模块。为了解决这个问题,你可以注册一个全局范围的过滤器直接为任何模块设置过滤器:
app.module.ts
当使用此方法对过滤器执行依赖注入时,请注意,无论采用哪种结构的模块,过滤器实际上都是全局的。 应该在哪里做? 选择定义了过滤器(以上示例中为 HttpExceptionFilter)的模块。 同样,useClass不是处理自定义提供程序注册的唯一方法。 在这里了解更多。
您可以根据需要添加任意数量的过滤器;只需将每个组件添加到 providers(提供者)数组。
ℹ️
为了捕获每一个未处理的异常(不管异常类型如何),将 @Catch() 装饰器的参数列表设为空
 

管道 Pipe

管道是具有 @Injectable() 装饰器的类。管道应实现 PipeTransform 接口。
notion image
管道有两个典型的应用场景:
  • 转换:管道将输入数据转换为所需的数据输出(例如,将字符串转换为整数)
  • 验证:对输入数据进行验证,如果验证成功继续传递; 验证失败则抛出异常
    • notion image
在这两种情况下, 管道 参数(arguments) 会由 控制器(controllers)的路由处理程序 进行处理。Nest 会在调用这个方法之前插入一个管道,管道会先拦截方法的调用参数,进行转换或是验证处理,然后用转换好或是验证好的参数调用原方法。
Nest自带很多开箱即用的内置管道。你还可以构建自定义管道。本章将介绍先内置管道以及如何将其绑定到路由处理程序(route handlers)上,然后查看一些自定义管道以展示如何从头开始构建自定义管道。

内置管道

Nest 自带九个开箱即用的管道,即
  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe
他们从 @nestjs/common 包中导出。
我们先来快速看看如何使用ParseIntPipe。这是一个转换的应用场景,管道确保传给路由处理程序的参数是一个整数(若转换失败,则抛出异常)。在本章后面,我们将展示 ParseIntPipe 的简单自定义实现。下面的示例写法也适用于其他内置转换管道(ParseBoolPipeParseFloatPipeParseEnumPipeParseArrayPipe 和 ParseUUIDPipe,我们在本章中将其称为 Parse* 管道)。

绑定管道

为了使用管道,我们需要将一个管道类的实例绑定到合适的情境。在我们的 ParseIntPipe 示例中,我们希望将管道与特定的路由处理程序方法相关联,并确保它在该方法被调用之前运行。我们使用以下构造来实现,并其称为在方法参数级别绑定管道:
这确保了我们在 findOne() 方法中接收的参数是一个数字(与 this.catsService.findOne() 方法的诉求一致),或者在路由处理程序被调用之前抛出异常。
举个例子,假设路由是这样子的
Nest将会抛出这样的异常:
这个异常阻止了 findOne() 方法的执行。
在上述例子中,我们传递了一个类(ParseIntPipe),而不是一个实例,将实例化留给框架去处理,做到了依赖注入。对于管道和守卫,我们也可以选择传递一个实例。如果我们想通过传递选项来自定义内置管道的行为,传递实例很有用:
绑定其他转换管道(即所有 Parse* 管道)的方法类似。这些管道都在验证路由参数、查询字符串参数和请求体正文值的情境中工作。
验证查询字符串参数的例子:

自定义管道

创建自定义管道

notion image
为实现 PipeTransfrom,每个管道必须声明 transfrom() 方法。该方法有两个参数:
  • value
  • metadata
value 参数是当前处理的方法参数(在被路由处理程序方法接收之前),metadata 是当前处理的方法参数的元数据。元数据对象具有以下属性:
这些属性描述了当前处理的参数。
参数
描述
type
告诉我们参数是一个 body @Body(),query @Query(),param @Param() 还是自定义参数 在这里阅读更多
metatype
参数的元类型,例如 String。 如果在函数签名中省略类型声明,或者使用原生 JavaScript,则为 undefined
data
传递给装饰器的字符串,例如 @Body('string')。如果您将括号留空,则为 undefined
⚠️
TypeScript 中的 interface 在转译期间会消失。因此,如果方法参数的类型被声明为接口(interface)而不是类(class),则 metatype 将是 Object

create-user.pipe.ts

将之前的数据验证放入管道中

添加到Body中

基于结构的验证

让我们把验证管道变得更有用一点。仔细看看 CatsController 的 create() 方法,我们希望在该方法被调用之前,请求主体(post body)得到验证。
注意到请求体参数为 createCatDto,其类型为 CreateCatDto :
create-cat.dto.ts
我们希望任何被该方法接收的请求主体都是有效的,因此我们必须验证 createCatDto 对象的三个成员。我们可以在路由处理程序方法中执行此操作,但这样做并不理想,因为它会破坏单一职责原则 (single responsibility rule, SRP)。
另一种做法是创建一个验证类,把验证逻辑放在验证类中。这样做的缺点是我们必须要记得在每个该方法的前面,都调用一次验证类。
那么写一个验证中间件呢?可以,但做不到创建一个能在整个应用程序上下文中使用的通用中间件。因为中间件不知道执行上下文(execution context),包括将被调用的处理程序及其任何参数。
管道就是为了处理这种应用场景而设计的。让我们继续完善我们的验证管道。

对象结构验证

有几种方法可以实现。一种常见的方式是使用基于结构的验证。我们来尝试一下。
Joi 库允许使用可读的 API 以直接的方式创建 schema,让我们构建一个基于 Joi schema 的验证管道。
首先安装依赖:
在下面的代码中,我们先创建一个简单的 class,在构造函数中传递 schema 参数。然后我们使用 schema.validate() 方法验证参数是否符合提供的 schema。
就像前面说过的,验证管道要么返回该值,要么抛出一个错误。
在下一节中,你将看到我们如何使用 @UsePipes() 修饰器给指定的控制器方法提供需要的 schema。这么做能让验证管道跨上下文重用,像我们准备做的那样。

绑定验证管道

在之前,我们已经了解如何绑定转换管道(像 ParseIntPipe 和其他 Parse* 管道)。
绑定验证管道也十分直截了当。
在这种情况下,我们希望在方法调用级别绑定管道。在当前示例中,我们需要执行以下操作使用 JoiValidationPipe
  1. 创建一个 JoiValidationPipe 实例
  1. 传递上下文特定的 Joi schema 给构造函数
  1. 绑定到方法
我们用 @UsePipes() 装饰器来完成。代码如下:
从 @nestjs/common 包导入 @UsePipes() 装饰器

类验证器

本节中的技术需要 TypeScript ,如果您的应用是使用原始 JavaScript编写的,则这些技术不可用。
让我们看一下验证的另外一种实现方式。
Nest 与 class-validator 配合得很好。这个优秀的库允许您使用基于装饰器的验证。装饰器的功能非常强大,尤其是与 Nest 的 Pipe 功能相结合使用时,因为我们可以通过访问 metatype 信息做很多事情,在开始之前需要安装一些依赖。
安装完成后,我们就可以向 CreateCatDto 类添加一些装饰器。在这里,我们看到了这种技术实现的一个显著优势:CreateCatDto 类仍然是我们的 Post body 对象的单一可靠来源(而不是必须创建一个单独的验证类)。
create-cat.dto.ts
现在我们来创建一个 ValidationPipe 类。
validate.pipe.ts
上面代码,我们使用了 class-transformer 库。它和 class-validator 库由同一个作者开发,所以他们配合的很好。
让我们来看看这个代码。首先你会发现 transform() 函数是 异步 的, Nest 支持同步异步管道。这样做的原因是因为有些 class-validator 的验证是可以异步的(利用 Promise)
接下来请注意,我们正在使用解构赋值提取 metatype 字段(只从 ArgumentMetadata 中提取了该成员)赋值给 metatype 参数。这是一个先获取全部 ArgumentMetadata 然后用附加语句提取某个变量的简写方式。
下一步,请观察 toValidate() 方法。当正在处理的参数是原生 JavaScript 类型时,它负责绕过验证步骤(它们不能附加验证装饰器,因此没有理由通过验证步骤运行它们)。
下一步,我们使用 class-transformer 的 plainToInstance() 方法将普通的 JavaScript 参数对象转换为可验证的类型对象。必须这样做的原因是传入的 post body 对象在从网络请求反序列化时不携带任何类型信息(这是底层平台(例如 Express)的工作方式)。 Class-validator 需要使用我们之前为 DTO 定义的验证装饰器,因此我们需要执行此转换,将传入的主体转换为有装饰器的对象,而不仅仅是普通的对象。
最后,如前所述,这就是一个验证管道,它要么返回值不变,要么抛出异常。
最后一步是绑定 ValidationPipe 。管道可以是参数范围(parameter-scoped)的、方法范围(method-scoped)的、控制器范围的(controller-scoped)或者全局范围(global-scoped)的。之前,我们已经见到了在方法层面绑定管道的例子,即利用基于 Joi 的验证管道。接下来的例子,我们会将一个管道实例绑定到路由处理程序的 @Body 装饰器上,让它能够检验 post body。
cats.controller.ts
当验证逻辑仅涉及一个指定的参数时,参数范围的管道非常有用。

全局管道

由于 ValidationPipe 被创建为尽可能通用,所以我们将把它设置为一个全局作用域的管道,用于整个应用程序中的每个路由处理器。
main.ts
全局管道用于整个应用程序、每个控制器和每个路由处理程序。
就依赖注入而言,从任何模块外部注册的全局管道(即使用了 useGlobalPipes(), 如上例所示)无法注入依赖,因为它们不属于任何模块。为了解决这个问题,可以使用以下构造直接为任何模块设置管道:
app.module.ts
ℹ️
请注意使用上述方式依赖注入时,请牢记无论哪种模块采用了该结构,管道都是全局的。那么它应该放在哪里呢?答案是选择管道(例如上面例子中的 ValidationPipe)被定义的模块。另外,useClass 并不是处理自定义提供者注册的唯一方法。在这里了解更多。

转换的应用场景

验证不是管道唯一的用处。在本章的开始部分,我已经提到管道也可以将输入数据转换为所需的输出。这是可以的,因为从 transform 函数返回的值完全覆盖了参数先前的值。
在什么时候有用?有时从客户端传来的数据需要经过一些修改(例如字符串转化为整数),然后处理函数才能正确的处理。还有种情况,有些数据的必填字段缺失,那么可以使用默认值。转换管道被插入在客户端请求和请求处理程序之间用来处理客户端请求。
这是一个简单的 ParseIntPipe,负责将字符串转换为整数。(如上所述,Nest 有一个更复杂的内置 ParseIntPipe; 这个例子仅作为自定义转换管道的简单示例)
parse-int.pipe.ts
如下所示, 我们可以很简单的配置管道来处理所参数 id:
由于上述结构,ParseIntpipe 将在请求触发相应的处理程序之前执行。
另一个有用的例子是按 ID 从数据库中选择一个现有的用户实体
请读者自己实现, 这个管道接收 id 参数并返回 UserEntity 数据, 这样做就可以抽象出一个根据 id 得到 UserEntity 的公共管道, 你的程序变得更符合声明式(Declarative 更好的代码语义和封装方式), 更 DRY (Don’t repeat yourself 减少重复代码) 编程规范.

提供默认值

Parse* 管道期望参数值是被定义的。当接收到 null 或者 undefined 值时,它们会抛出异常。为了允许端点处理丢失的查询字符串参数值,我们必须在 Parse* 管道对这些值进行操作之前注入默认值。DefaultValuePipe 提供了这种能力。只需在相关 Parse* 管道之前的 @Query() 装饰器中实例化 DefaultValuePipe,如下所示:
 

守卫 Guard

守卫是一个使用 @Injectable() 装饰器的类。 守卫应该实现 CanActivate 接口。
notion image
守卫有一个单独的责任。它们根据运行时出现的某些条件(例如权限,角色,访问控制列表等)来确定给定的请求是否由路由处理程序处理。这通常称为授权。在传统的 Express 应用程序中,通常由中间件处理授权(以及认证)。中间件是身份验证的良好选择,因为诸如 token 验证或添加属性到 request 对象上与特定路由(及其元数据)没有强关联。
中间件不知道调用 next() 函数后会执行哪个处理程序。另一方面,守卫可以访问 ExecutionContext 实例,因此确切地知道接下来要执行什么。它们的设计与异常过滤器、管道和拦截器非常相似,目的是让您在请求/响应周期的正确位置插入处理逻辑,并以声明的方式进行插入。这有助于保持代码的简洁和声明性。
ℹ️
守卫在每个中间件之后执行,但在任何拦截器或管道之前执行。

授权守卫

正如前面提到的,授权是守卫的一个很好的用例,因为只有当调用者(通常是经过身份验证的特定用户)具有足够的权限时,特定的路由才可用。我们现在要构建的 AuthGuard 假设用户是经过身份验证的(因此,请求头附加了一个token)。它将提取和验证token,并使用提取的信息来确定请求是否可以继续。
auth.guard.ts
validateRequest() 函数中的逻辑可以根据需要变得简单或复杂。本例的主要目的是说明守卫如何适应请求/响应周期。
每个守卫必须实现一个 canActivate() 函数。此函数应该返回一个布尔值,用于指示是否允许当前请求。它可以同步或异步地返回响应(通过 Promise 或 Observable)。Nest 使用返回值来控制下一个行为:
  • 如果返回 true, 将处理用户调用。
  • 如果返回 false, 则 Nest 将忽略当前处理的请求。
notion image
 

控制器守卫

全局守卫

📌
弊端
无法访问 Service
全局守卫用于整个应用程序, 每个控制器和每个路由处理程序。在依赖注入方面, 从任何模块外部注册的全局守卫 (使用 useGlobalGuards(),如上面的示例中所示)不能插入依赖项, 因为它们不属于任何模块。为了解决此问题, 您可以使用以下构造直接从任何模块设置一个守卫:

为每个处理器设置角色

我们的 RolesGuard 现在在正常工作,但还不是很智能。我们仍然没有利用最重要的守卫的特征,即执行上下文。它还不知道角色,或者每个处理程序允许哪些角色。例如,CatsController 可以为不同的路由提供不同的权限方案。其中一些可能只对管理用户可用,而另一些则可以对所有人开放。我们如何以灵活和可重用的方式将角色与路由匹配起来?
这就是自定义元数据发挥作用的地方(从这里了解更多)。Nest 提供了通过 @SetMetadata() 装饰器将定制元数据附加到路由处理程序的能力。这些元数据提供了我们所缺少的角色数据,而守卫需要这些数据来做出决策。让我们看看使用@SetMetadata():
cats.controller.ts
@SetMetadata() 装饰器需要从 @nestjs/common 包导入。
通过上面的构建,我们将 roles 元数据(roles 是一个键,而 ['admin'] 是一个特定的值)附加到 create() 方法。虽然这样可以运行,但直接使用 @SetMetadata() 并不是一个好做法。相反,你应该创建你自己的装饰器。
roles.decorator.ts
这种方法更简洁、更易读,而且是强类型的。现在我们有了一个自定义的 @Roles() 装饰器,我们可以使用它来装饰 create()方法。
cats.controller.ts
让我们再次回到 RolesGuard 。 它只是在所有情况下返回 true,到目前为止允许请求继续。我们希望根据分配给当前用户的角色与正在处理的当前路由所需的实际角色之间的比较来设置返回值的条件。 为了访问路由的角色(自定义元数据),我们将使用在 @nestjs/core 中提供的 Reflector 帮助类。
roles.guard.ts
ℹ️
在 node.js 世界中,将授权用户附加到 request 对象是一种常见的做法。 因此,在上面的示例代码中。我们假设 request.user 包含用户实例和允许的角色。 在您的应用中,您可能会在自定义身份验证(或中间件)中建立该关联。查阅此处以了解更多。
 

拦截器 Intercept

拦截器是使用 @Injectable() 装饰器注解的类。拦截器应该实现 NestInterceptor 接口。
notion image
拦截器具有一系列有用的功能,这些功能受面向切面编程(AOP)技术的启发。它们可以:
  • 在函数执行之前/之后绑定额外的逻辑
  • 转换从函数返回的结果
  • 转换从函数抛出的异常
  • 扩展基本函数行为
  • 根据所选条件完全重写函数 (例如, 缓存目的)

基础

每个拦截器都有 intercept() 方法,它接收2个参数。 第一个是 ExecutionContext 实例(与守卫完全相同的对象)。 ExecutionContext 继承自 ArgumentsHost 。 ArgumentsHost 是传递给原始处理程序的参数的一个包装 ,它根据应用程序的类型包含不同的参数数组。你可以在这里读更多关于它的内容(在异常过滤器章节中)。

执行上下文

通过扩展 ArgumentsHostExecutionContext 还添加了几个新的帮助程序方法,这些方法提供有关当前执行过程的更多详细信息。这些详细信息有助于构建可以在广泛的控制器,方法和执行上下文中使用的更通用的拦截器。ExecutionContext 在此处了解更多信息。

调用处理程序

第二个参数是 CallHandler。如果不手动调用 handle() 方法,则主处理程序根本不会进行求值。这是什么意思?基本上,CallHandler是一个包装执行流的对象,因此推迟了最终的处理程序执行。
比方说,有人提出了 POST /cats 请求。此请求指向在 CatsController 中定义的 create() 处理程序。如果在此过程中未调用拦截器的 handle() 方法,则 create() 方法不会被计算。只有 handle() 被调用(并且已返回值),最终方法才会被触发。为什么?因为Nest订阅了返回的流,并使用此流生成的值来为最终用户创建单个响应或多个响应。而且,handle() 返回一个 Observable,这意味着它为我们提供了一组非常强大的运算符,可以帮助我们进行例如响应操作。
📌
@UseInterceptors()n装饰器从 @nestjs/common 导入。
ℹ️
拦截器的作用与控制器,提供程序,守卫等相同,这意味着它们可以通过构造函数注入依赖项。
由于 handle() 返回一个RxJS Observable,我们有很多种操作符可以用来操作流。在上面的例子中,我们使用了 tap() 运算符,该运算符在可观察序列的正常或异常终止时调用函数。

使用

  1. 创建interceptor
  1. 编辑interceptor
  1. 注册
方法:@UseInterceptors(LoggingInterceptor)
控制器:@UseInterceptors(LoggingInterceptor)
全局:app.useGlobalInterceptors(new LoggingInterceptor())
 
全局拦截器用于整个应用程序、每个控制器和每个路由处理程序。在依赖注入方面, 从任何模块外部注册的全局拦截器 (如上面的示例中所示) 无法插入依赖项, 因为它们不属于任何模块。为了解决此问题, 您可以使用以下构造直接从任何模块设置一个拦截器:
app.module.ts
  1. 访问输出结果
 
notion image

响应映射

我们已经知道, handle() 返回一个 Observable。此流包含从路由处理程序返回的值, 因此我们可以使用 map() 运算符轻松地对其进行改变。
响应映射功能不适用于特定于库的响应策略(禁止直接使用 @Res() 对象)。
让我们创建一个 TransformInterceptor, 它将打包响应并将其分配给 data 属性。
transform.interceptor.ts
Nest 拦截器就像使用异步 intercept() 方法的魅力一样, 意思是, 如果需要,您可以毫不费力地将方法切换为异步。
之后,当有人调用GET /cats端点时,请求将如下所示(我们假设路由处理程序返回一个空 arry []):
拦截器在创建用于整个应用程序的可重用解决方案时具有巨大的潜力。例如,我们假设我们需要将每个发生的 null 值转换为空字符串 ''。我们可以使用一行代码并将拦截器绑定为全局代码。由于这一点,它会被每个注册的处理程序自动重用。

异常映射

另一个有趣的用例是利用 catchError() 操作符来覆盖抛出的异常:
exception.interceptor.ts

Stream 重写

有时我们可能希望完全阻止调用处理程序并返回不同的值 (例如, 由于性能问题而从缓存中获取), 这是有多种原因的。一个很好的例子是缓存拦截器,它将使用一些TTL存储缓存的响应。不幸的是, 这个功能需要更多的代码并且由于简化, 我们将仅提供简要解释主要概念的基本示例。
cache.interceptor.ts
这是一个 CacheInterceptor,带有硬编码的 isCached 变量和硬编码的响应 [] 。我们在这里通过 of 运算符创建并返回了一个新的流, 因此路由处理程序根本不会被调用。当有人调用使用 CacheInterceptor 的端点时, 响应 (一个硬编码的空数组) 将立即返回。为了创建一个通用解决方案, 您可以利用 Reflector 并创建自定义修饰符。反射器 Reflector 在守卫章节描述的很好。

更多操作符

使用 RxJS 运算符操作流的可能性为我们提供了许多功能。让我们考虑另一个常见的用例。假设您要处理路由请求超时。如果您的端点在一段时间后未返回任何内容,则您将以错误响应终止。以下构造可实现此目的:
timeout.interceptor.ts
5秒后,请求处理将被取消。您还可以在抛出之前添加自定义逻辑RequestTimeoutException(例如,释放资源)。
 

参考链接

  1. Nest.js 中文文档 (nestjs.cn)
 
上一篇
NestJS 学习 —— 网关 Gateway
下一篇
NestJS 学习 —— 入门

评论
Loading...