快速入门nest.js(8/10)--其他更多模块

519 阅读11分钟

基本

NestJS中,我们还有4个额外的功能构建块。

image-20220428104553625

嵌套构建块可以是:

  1. 全局范围
  2. 控制器范围
  3. 方法范围
  4. 参数范围<仅适用于管道>

image-20220428105458428

这些不同的绑定拘束为您提供了应用程序中不同级别的力度和控制,每个都不会覆盖另外一个,而是分层在顶部。

进入main.ts我们看到之前就是用过全局的管道:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
​
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
    forbidNonWhitelisted: true,
    transformOptions: {
      enableImplicitConversion: true,
    },
  }));
  await app.listen(3000);
}
bootstrap();

自行设置和实例化它的一大限制是:我们不能在这里注入任何的依赖,因为我们将它设置在任何NestJS模块上下文之外,那我们该如何解决这个问题呢?

我们可以选择使用基于自定义提供程序的语法直接从Nest模块内部设置管道

// app.module
providers: [AppService,{provide: APP_PIPE, useClass: ValidationPipe}],

APP_MODULE是由@nestjs/core中导出的特殊令牌,以这种方式提供ValidationPipe,可以让我们在AppModule的范围内实例化ValidationPipe并在创建后将其注册为全局管道<其他构建模块功能也有相同的标记>。

假设我们想将ValidationPipe绑定到仅在CoffeesController中定义的每个路由处理程序

@UsePipes(ValidationPipe)
@Controller('coffees')
export class CoffeesController {
    // ...

你也可以传递一个实例:

@UsePipes(new ValidationPipe())
@Controller('coffees')
export class CoffeesController {
    // ...

从而在实现this确切场景时,非常有用。当然,最佳实践为使用类而不是实例,这减少了内存使用,因为Nest可以在整个模块中轻松重用同一类的实例

方法范围:

  @UsePipes(ValidationPipe)
  @Get(':id')
  findOne(@Param('id') id: string) {
    // 选择传入某个字符串
    return this.coffeeService.findOne(id); // 使用service中的方法替换之前写的空方法
  }

仅适用于pipe的参数范围:

  @Patch(':id')
  update(@Param('id') id: string, @Body(ValidationPipe) updateCoffeeDto: UpdateCoffeeDto) {
    return this.coffeeService.update(id, updateCoffeeDto);
  }

捕捉异常ExceptionFilter

// nest g filter common/filters/http-exception 
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Response } from 'express';
​
@Catch(HttpException) // 处理的是HttpException
export class HttpExceptionFilter<T extends HttpException>
  implements ExceptionFilter
{
  catch(exception: T, host: ArgumentsHost) {
    const ctx = host.switchToHttp(); // 这个switchToHttp可以使我们能够访问本机飞行请求或响应对象
    const response = ctx.getResponse<Response>(); // 此方法返回我们的底层平台响应,默认情况下是Express
​
    const status = exception.getStatus();
    const exceptionResponse = exception.getResponse();  // 获取原始异常响应
    const error =
      typeof response === 'string'  // 为了错误统一返回object
        ? { message: exceptionResponse }
        : (exceptionResponse as object);
​
    response.status(status).json({
      ...error
    });  // 发回响应设置statusCode
  }
}

到目前为止,这里的HttpExceptionFilter还没有任何真正做任何独特的事情。

比如现在我们可以增加这个信息;

    response.status(status).json({
      ...error,
      timestamp: new Date().toISOString()   // 增加的
    });  // 发回响应设置statusCode

由于我们不需要任何外部提供程序,因此我们可以使用main.ts文件中的app实例全局绑定这个ExceptionFilter

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

然后现在我们测试它:

image-20220428133913286

路由守卫

可以用来检验token是否有效,从而进行下一步的请求

首先创建一个负责两件事的Guard

  1. 验证API_KEY是否存在于授权标头中;
  2. 其次确定是否将正在访问的路由指定为公共的(私有的必须有API_KEY才能访问);

首先:

// nest g guard common/guard/api-key

common这个文件夹我们可以在其中保存任何与特定于无关的东西。

守卫的一个重要要求就是要实现从@nest/common导出的canActive接口

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

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {  // 可以返回的类型
    return true;
  }
}

这个类返回的bool值指定当前请求是被允许继续还是拒绝访问。

然后在main.ts中添加我们新的ApiGuardappUseGlobalCuards()

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

为了保证api_key不被推送,我们将api_key定义为环境变量。

// .env
// ...
APP_KEY=67whdwjh27uhd2duhw8d2udhiwjd
  • 然后在守卫这里,我们希望任何未标记为公共的请求需要验证API_KEY
  • 这里假设调用者将此密钥作为authorization header传递;
  • 获取HTTP请求相关的信息,我们需要从继承自ArgumentsHostExecutionContext访问它;
//  api-key.guard
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Request } from 'express';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context
      .switchToHttp() // 这个方法可以让我们访问本机运行中的Request\Response\Next objects
      .getRequest<Request>();
    const authHeader  = request.header('Authorization');
    return authHeader === process.env.API_KEY;
  }
}

然后测试它:

image-20220430160913734

image-20220430160845113

这里我们需要实现上一节提到的检测当前路由是否被声明为公共的。

@SetMetadata

那么我们该以哪种方式指定应用程序中的哪些端点是公共的呢?或者想要任何数据与控制器或路由一起存储?

这就是自定义元数据发挥作用的地方:@SetMetadata

// coffees.controller
@SetMetadata('isPublic', true)  // 以k,v存储数据
@Get()
findAll(@Query() paginationQuery: PaginationQueryDto) {
    return this.coffeeService.findAll(paginationQuery);
}

封装装饰器

上述做法并不是最佳实践,我们可以自定义装饰器@public来实现同样的功能。

首先,在/common/下创建一个名为decorators的文件夹用来存储我们可能制作的任何其他未来的装饰器,然后创建public.decorator,这个文件我们要导出两个东西:

import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = true;  // 导出它我们就可以在任何地方查看这个元数据

export const Public = ()=>SetMetadata(IS_PUBLIC_KEY, true);

然后换掉:

@Public()
@Get()
findAll(@Query() paginationQuery: PaginationQueryDto) {
    return this.coffeeService.findAll(paginationQuery);
}

Reflector类

为了在路由守卫中访问我们的路由元数据,我们需要使用Reflector类,它允许我们在特定上下文检索元数据。

首先在constructor中注入该类:

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,  // 这里
    private readonly configService: ConfigService,
  ) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler()); // 第二个参数为目标上下文
    if (isPublic) return true;  // 公共的直接返回
    const request = context
      .switchToHttp() // 这个方法可以让我们访问本机运行中的Request\Response\Next objects
      .getRequest<Request>();
    const authHeader = request.header('Authorization');

    return authHeader === this.configService.get('API_KEY');  // 不应该使用process.env.API_KEY,所以替换为现在这样
  }
}

这时候直接运行会出现如下错误:

image-20220430175701867

这是因为依赖于其他类的全局守卫必须在@Module上下文中注册(这样才能被实例化),我们可以直接在/common/文件夹中创建一个module文件nest g mo common

// common.module
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ApiKeyGuard } from './guard/api-key.guard';

@Module({
  imports: [ConfigModule],  // 为了使用ConfigService
  providers: [{provide: APP_GUARD, useClass: ApiKeyGuard}]
})
export class CommonModule {}

这里局部配置了,所以我们需要在main.ts中删除useGlobalGuard;

测试发现不设置任何的headers也能请求成功:

image-20220430180605773

而请求没有@Public的路由并不设置Headers会请求失败。

image-20220430180724618

拦截器

拦截器通过向现有代码添加额外的行为而无需修改代码本身,它可以使我们:

  • 在方法执行之前或之后绑定额外的逻辑;
  • 转换从方法返回的结果;
  • 转换方法抛出的异常;
  • 扩展基本方法行为;
  • 甚至覆盖一个方法-取决于特定条件

例如做一些像缓存各种响应这样的事情;

这里创建一个例子,希望我们所有的响应都有data属性。这里创建的拦截器将拦截处理所有传入的请求,并自动为我们包装我们的数据:

初始

// nest g interceptor common/interceptor/wrap-response

同样,这里需要实现一个NestInterceptor接口:

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

@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {  // 返回RXJS,是一种promise的强大替代品
    return next.handle();  // 使用此方法在拦截器中调用路由处理程序方法,如果没有调用handle()方法,路由处理程序将不会被执行
    // 这相当于允许我们在handle之前和之后实现自定义逻辑
  }
}

log例子

<注意如何在之后自定义逻辑的>:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 返回RXJS,是一种promise的强大替代品
    console.log('Before...');

    return next.handle().pipe(tap((data) => console.log('After...', data))); // 使用此方法在拦截器中调用路由处理程序方法,如果没有调用handle()方法,路由处理程序将不会被执行
    // 这相当于允许我们在handle之前和之后实现自定义逻辑
  }
}
// tap()在Observable流正常终止时调用Log函数,并且不会干扰响应周期。

同样我们还是需要将其导入才能使用(这里是全局导入):

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

然后测试POST一个请求创建coffee之后:

image-20220501144942169

数据包装器

现在我们实现最开始提到的数据包装器:

@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 返回RXJS,是一种promise的强大替代品
    console.log('Before...');

    return next.handle().pipe(map((data)=>({data})))  // map()从流中获取一个值并返回修改后的值
  }
}

测试(被包裹在data属性下面了):

image-20220501151914614

处理超时

nest g interceptor common/interceptor/timeout
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, timeout } from 'rxjs';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(3000));  // 意味着3秒之后自动取消
  }
}

同样将其绑定到全局:

// main.ts
app.useGlobalInterceptors(new WrapResponseInterceptor(), new TimeoutInterceptor());
// 这里可以绑定多个拦截器,只需要将其用逗号隔开就可以了

测试(这里在findall里面设置一个很长的setimeout来模拟)

// 测试用例
async findAll(@Query() paginationQuery: PaginationQueryDto) {
    await new Promise(resolve => setTimeout(resolve, 5000))
    return this.coffeeService.findAll(paginationQuery);
}

image-20220501233915489

然而这个message并不是特别的友好,我们应该如何修改它呢?

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(3000),
      catchError((err) => {  // 捕获流中的错误
        if(err instanceof TimeoutError){
          return throwError(() => new RequestTimeoutException())
        }
        return throwError(() => err);
      }),
    );
  }
}

image-20220501234542378

创建常规管道

先前

管道通常的两个用例:

  • 转换
  • 验证

nest在方法被调用前除法一个管道,管道也会接收要传递给方法的参数,nest提供了几个开箱即用的管道:

  • ValidationPipe
  • ParseArrayPipe:解析和验证数组;

构建自己的Pipes

创建一个管道,它会自动将任何传入的字符串解析为整数ParseIntPipe(当然nest已经有现成的pipe可以使用,这里为了学习而重新实现)

// nest g pipe common/pipes/parse-int

和之前的差不多,这里需要实现的是PipeTransform接口

import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

添加转换的逻辑:

import {
  ArgumentMetadata,
  BadRequestException,
  Injectable,
  PipeTransform,
} from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform {
  transform(value: string, metadata: ArgumentMetadata) {
    // value: 当前处理的参数在路由处理方法接收之前的输入值, metadata: 当前处理参数的元数据
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException(
        `Validation falied. "${val} is not an integer"`,
      );
    }
    return val;
  }
}

现在,我们就可以将我们的管道绑定到@Param()上了;

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: string) {  // 作为第二个参数传入
    return this.coffeeService.findOne(id);
}
// 如果不做处理,当我们传入abc作为参数时,会返回500,而这里处理之后则会返回更加友好的状态信息400以及错误原因。

中间件

中间件是一个在处理路由处理程序和其他构建块之前调用的函数,这包括了拦截器、守卫和管道。中间件可以访问请求和响应对象,并且不专门绑定到任何方法,而是绑定到指定的路由路径。中间件函数可以执行以下任务:

  • 执行代码
  • 更改请求和响应对象
  • 结束请求响应周期
  • 甚至在调用堆栈中调用next()中间件函数

使用中间件是时,如果当前中间件函数没有结束请求/响应周期,它就必须调用next()方法,该方法将控制权传递给下一个中间件函数,否则,请求将被挂起永远不会完成。

中间件可以是函数和类:

  • 函数中间件是无状态的,它不能被注入依赖项,并且无权访问nest容器;
  • 类中间件可以依赖外部依赖并注入在同一模块范围内的提供程序;

创建:

// nest g middleware common/middleware/logging

同样这里需要实现NestMiddleware接口:

import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class LoggingMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    next();
  }
}

在这之前先要在common.module中去注册它,在这里,我们首先要确保CommonModule继承于NestModule接口,这个接口需要我们提供configure()方法它以MiddlewareConsumer作为参数。MiddlewareConsumer提供了一组有用的方法来将中间件绑定到特定的路由。

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ApiKeyGuard } from './guard/api-key.guard';
import { LoggingMiddleware } from './middleware/logging.middleware';

@Module({
  imports: [ConfigModule],  // 为了使用ConfigService
  providers: [{provide: APP_GUARD, useClass: ApiKeyGuard}]
})
export class CommonModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
      consumer.apply(LoggingMiddleware).forRoutes('*');  // 这里将其绑定到所有路由上
      // consumer.apply(LoggingMiddleware).forRoutes({path: 'coffees', method: RequestMethod.GET});  // 这里将其绑定到特定路由特定方法上
      // consumer.apply(LoggingMiddleware).exclude('coffees').forRoutes('*');  // 这里先排除某类路由
  }
}

实现一个记录往返时间的中间件:

import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class LoggingMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    console.time("Request-response time");
    console.log("Hi from middleware!");
    res.on('finish', ()=>console.timeEnd('Request-response time'))
    next();
  }
}

image-20220503131154970

自定义装饰器

这里创建一个获取协议的参数装饰器(效果和@Body获取request.body差不多):

import { createParamDecorator, ExecutionContext } from "@nestjs/common";

export const Protocol =  createParamDecorator(  // 这个方法
  (data: unknown, ctx: ExecutionContext)=>{  // data是获取装饰器传递过来的参数,如@Protocol('https')就是h
    const request = ctx.switchToHttp().getRequest();
    return request.protocol;
  }
)

使用:

@Public()
@Get()
async findAll(@Protocol() protocol, @Query() paginationQuery: PaginationQueryDto) {
    console.log(protocol);
    return this.coffeeService.findAll(paginationQuery);
}

\