阅读 295

最佳实践MVC模式的Node框架Nest.JS最全介绍

Middleware中间件

中间件是用来做什么的?

  • 执行任何代码
  • 更改请求/响应对象
  • 结束请求-响应周期
  • 调用堆栈中的下一个中间件函数
  • 如果当前的中间件函数没有结束请求-响应周期,那么必须调用next()将控制权传递给下一个中间件函数。否则请求会一直停留在挂起状态。

使用中间件

Nest没有实现中间件装饰器,我们使用模块类的configure方法。因此需要使用中间件的模块都需要implements NestModule

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
复制代码

使用方法

  1. 路由名称 forRoutes('cats')
  2. 控制器forRoutes(CatsController)
  3. 具体请求类型forRoutes({ path: 'cats', method: RequestMethod.GET })
  4. 路由通配符forRoutes({ path: 'ab*cd', method: RequestMethod.ALL })
  5. 剔除某些请求
consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST },
    'cats/(.*)',
  )
  .forRoutes(CatsController);
复制代码

函数式中间件

我们可以将中间件写成一个函数:

import { Request, Response } from 'express';

export function logger(req: Request, res: Response, next: Function) {
  console.log(`Request...`);
  next();
};
复制代码

如何使用函数式中间件

consumer
  .apply(logger)
  .forRoutes(CatsController);
复制代码

全局使用

INestApplication实例有一个use()方法:

const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);
复制代码

Filters异常过滤器

什么是Filters

Nest带有内置的异常处理层,该层负责处理整个应用中所有未被处理的异常。 当你的代码未处理异常时,该层将捕获该异常,然后发送对用户比较友好的响应回去。

内置的HttpException

Exception filters机制是开箱即用的,此操作由内置的全局异常过滤器执行,该过滤器处理HttpException类(及其子类)的异常。如果无法识别异常(既不是HttpException也不是从HttpException继承的类),则内置异常过滤器将生成以下默认JSON响应:

{
  "statusCode": 500,
  "message": "Internal server error"
}
复制代码

我们可以随意使用内置的HttpException捕获:

@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
复制代码

这样的话客户端会收到这样的响应:

{
  "statusCode": 403,
  "message": "Forbidden"
}
复制代码

要仅覆盖JSON响应主体的消息部分,我们只需要在response参数中提供一个字符串。 要覆盖整个JSON响应主体,我可以在response参数中传递一个对象。比如:

@Get()
async findAll() {
  throw new HttpException({
    status: HttpStatus.FORBIDDEN,
    error: 'This is a custom message',
  }, HttpStatus.FORBIDDEN);
}
复制代码

这样的话,响应就是下面的样子了:

{
  "status": 403,
  "error": "This is a custom message"
}

复制代码

内置HttpException子类方便使用

Nest为我们提供了一组继承于HttpException的异常Filters,比如:

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ...

所以,下面的代码作用是相同的:

 throw new HttpException('user not exist',HttpStatus.BAD_REQUEST)
 throw new BadRequestException('User not exist');
复制代码

为什么要自定义Filters

虽然内置Exception Filter可以为您自动处理许多情况,但您可能希望完全控制异常层。 例如,您可能要添加日志记录或基于一些动态因素使用其他JSON Schema。 异常过滤器正是为此目的而设计的。它们使您可以把握精确的控制流以及将响应的内容发送回客户端。

如何自定义一个Filter

例如我们自定义一个HttpExceptionFilter

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        message:exception.message,
        error:exception.name,
        timeStamp: new Date().toString(),
        path: request.url
      });
  }
}
复制代码

所有的异常处理过滤器都需要implements通用的ExceptionFilter<T> 接口,这需要你去实现catch(exception: T, host: ArgumentsHost)方法。T表示exception的类型。

然后客户端收到的异常处理就会是类似这样的:

{
   "statusCode": 401,
   "message": "Unauthorized",
   "error": "Error",
   "timeStamp": "Thu Jul 09 2020 11:38:07 GMT+0800 (中国标准时间)",
   "path": "/user/aaa"
}
复制代码

上面的自定义Filters代码里,@Catch(HttpException)装饰器将所需的元数据绑定到异常过滤器,它的作用其实很简单,仅仅是告诉Nest这个过滤器正在寻找HttpException类型的异常,没什么其他复杂的东西。 @Catch()装饰器可以采用单个参数,也可以采用逗号分隔的列表。 这样可以同时为几种类型的异常设置过滤器。

Arguments Host

让我们看一下catch()方法的参数。exception是当前正在处理的异常对象,host参数是ArgumentsHost对象。 ArgumentsHost是一个功能强大的实体对象。在此代码示例中,我们只是使用它获取被传递给原始请求处理(在异常发生所在的控制器中)的RequestResponse对象的引用。我们在ArgumentsHost上使用了一些辅助方法来获取所需的RequestResponse对象。在执行上下文章节了解有关ArgumentsHost的更多信息。

将Filters绑定到使用的地方

  • Method
@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}
复制代码
  • Controller
@UseFilters(new HttpExceptionFilter())
export class CatsController {}
复制代码
  • Global
  1. 使用useGlobalFilters
const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
复制代码

全局拦截器useGlobalInterceptors在整个应用中用于每个controller和每个routes。在依赖注入方面,从任何模块外部注册的全局拦截器(如使用useGlobalInterceptors)都无法注入依赖关系,因为这是在所有模块上下文之外完成的。为了解决这个问题,我们可以在任意模块里直接用下面的方式设置interceptor

  1. 使用任意Module

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}
复制代码

Pipes管道

管道是用@Injectable()装饰器注释的类。管道均应实现PipeTransform接口。

Pipes是用来干嘛的

  • 转换:将输入的数据转换为需要的格式(比如将字符串转为整型)
  • 验证:验证输入的数据,如果正确就简单的返回就好,如果不正确抛出一个异常(比如常见的验证用户id是否存在)

在这两种情况下,管道都对由controller route handler处理的arguments进行操作。 Nest会在调用方法之前插入一个管道,并且管道会接收参数并对其进行操作。此时将进行任何转换/验证操作,然后使用转换后的参数调用route handler

例如,ParseIntPipe管道将方法处理程序参数转换为JavaScript整型(如果转换失败,则抛出异常)。

内置的Pipes

Nest为我们提供了5个开箱即用的Pipe。

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe

如何/在哪里使用内置管道

将管道类的实例绑定到适当的上下文。在我们的ParseIntPipe示例中,我们希望将管道与特定的路由处理方法相关联,并确保它在该方法被调用之前运行。 我们使用以下方式,可以将其称为在方法参数级别(method parameter)绑定管道:

@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}
复制代码

如果我们传递了字符串而不是数字就会收到这样的响应:

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}
复制代码

这个异常会阻止 findOne方法执行。

传递Pipe类还是Pipe实例对象

在上面的示例中,我们传递了一个类(ParseIntPipe),而不是实例,我们将实例化的任务留给了框架并允许依赖注入。Pipe和Guard一样,我们可以传递实例对象。 如果我们要通过一些配置选项来丰富内置管道的行为,那么传递即时实例对象非常有用。例如:

@Get(':id')
async findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {
  return this.catsService.findOne(id);
}
复制代码

这样我们收到的异常状态码就是406 Not Acceptable

{
    "statusCode": 406,
    "message": "Validation failed (numeric string is expected)",
    "error": "Error",
    "timeStamp": "Thu Jul 09 2020 13:38:07 GMT+0800 (中国标准时间)",
    "path": "/user/aaa"
}
复制代码

自定义Pipes

比如自定义一个验证用户是否存在的Pipe:

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class UserByIdPipe implements PipeTransform<string> {
    constructor(private readonly usersService: UsersService) { }
    async transform(value: string, metadata: ArgumentMetadata) {
        const user = await this.usersService.findOne(value);
        if (user) {
            return user;
        } else {
            throw new BadRequestException('User not exist');
        }
    }
}
复制代码

PipeTransform <T,R>是必须由任何管道实现的通用接口。 通用接口使用T表示输入值的类型,并使用R表示transform()方法的返回类型。

每个管道都必须实现transform()方法以实现PipeTransform接口。 此方法有两个参数:

  • value
  • metedata

value参数是当前处理的方法参数,也就是传递进来的值。而metadata是当前处理的方法参数的元数据。元数据对象具有以下属性:

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
复制代码

使用自定义管道

 @Get(':id')
    getProfile(@Request() req, @Param('id', ParseIntPipe, UserByIdPipe) user: User) {
        return user;
    }

复制代码

如果用户存在,会返回用户信息。如果用户不存在,客户端会收到这个响应:

{
    "statusCode": 400,
    "message": "User not exist",
    "error": "Error",
    "timeStamp": "Thu Jul 09 2020 13:45:38 GMT+0800 (中国标准时间)",
    "path": "/user/22"
}
复制代码

有时候我们不希望直接返回用户信息,因为我们可能是在删除/注册用户之前,先验证一下。 在管道里:

 if (user) {
            return value;
        } else {
            throw new BadRequestException('User not exist');
        }
复制代码

如何使用:

 @Delete(':id')
    remove(@Param('id', ParseIntPipe, UserByIdPipe) id: string): Promise<void> {
        return this.usersService.remove(id);
    }
复制代码

转换数据的使用场景

验证不是定制管道的唯一场景。在前面我们提到管道还可以将输入数据转换为所需的格式,因为从transform方法返回的值将完全覆盖参数的先前值。

什么时候用转换呢?有时从客户端传递的数据需要进行一些更改(例如,将字符串转换为整数),然后才能通过路由处理方法对其进行正确处理。 此外,某些必填数据字段可能会丢失,我们希望可以使用默认值。转换管道可以通过在客户端请求和请求处理方法之间插入处理逻辑来完成这些功能。

下面的例子是一个我们自定义的ParseIntPipe,它负责将字符串解析为整数值。 (如上所述,Nest有一个内置的ParseIntPipe,它更加复杂;这里我们仅将下面这个管道作为自定义转换管道的简单示例)。

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

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}
复制代码

默认值填充管道

默认值管道DefaultValuePipe的使用

@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}

复制代码

如何使用管道

  • Parameter

    当验证逻辑只关心某个特定参数的时候,参数范围的管道非常有用

    
    @Get(':id')
      async findOne(@Param('id', ParseIntPipe) id: number) {
        return this.catsService.findOne(id);
      }
    复制代码

@Post() async create( @Body(new ValidationPipe()) createCatDto: CreateCatDto) { this.catsService.create(createCatDto); }


- **Golbal**

1. 使用`useGlobalPipes`
 ```typescript
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
复制代码
  1. 使用任意Module

    import { Module } from '@nestjs/common';
    import { APP_PIPE } from '@nestjs/core';
    
    @Module({
       providers: [
        {
          provide: APP_PIPE,
          useClass: ValidationPipe,
        },
          ],
      })
     export class AppModule {}
    复制代码

Guards路由守卫

什么是Guards

Guards是单一职责的。它们根据运行时出现的某些条件(例如权限,角色,ACL等)来确定给定的请求是否由路由处理程序处理。这通常称为授权。授权(以及它通常与之合作的东西——身份验证)通常由传统Express应用中的中间件处理。中间件是身份验证的不错选择,因为像令牌验证,或者向请求对象上添加属性等这样的事情,与路由之间没有强烈的关联。

但是,中间件有些其他问题。在调用next()函数后,它不知道哪个处理程序会被执行。而Guards可以访问ExecutionContext实例,因此确切知道下一步将要执行什么。它们的设计非常类似于异常过滤器,管道和拦截器,使您可以在请求/响应周期中的正确位置插入处理逻辑。

Guards在中间件之后,拦截器/管道之前执行。

如何实现Guards

如前所述,AuthorizationGuards的一个很好的用例,因为只有当调用者(通常是经过身份验证的特定用户)具有足够的权限时,特定的路由才可用。 现在,我们将构建的AuthGuard假设已通过身份验证的用户(Token已经加到了请求Headers中)。它将提取并验证token,并使用提取的信息来确定请求是否可以继续。

关于JWT Token Auth 验证,后面我会写篇文章讲如何实现。

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

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}
复制代码

每个Guards都必须实现canActivate()函数。 此函数应返回一个布尔值,表示是否允许当前请求。它可以同步或异步(通过Promise或Observable)的返回响应。Nest使用返回值控制下一步操作:

  • 如果返回true,则将处理请求。
  • 如果返回false,则Nest将拒绝该请求。

Execution context

canActivate()函数只有一个参数,即ExecutionContext实例。ExecutionContext继承自ArgumentsHost。我们上文在异常过滤器中看到过ArgumentsHost。在上面的示例中,我们只是使用与我们之前使用的ArgumentsHost上定义的相同的方法来获取对Request对象的引用。

通过继承ArgumentsHostExecutionContext还添加了几个方法,这些方法提供有关当前执行过程的更多详细信息。这些信息可以帮助我们构建可以在控制器、方法、执行上下文中工作的Guards。在此处了解有关ExecutionContext的更多信息。

如何使用Guards

  • Controller

    @Controller('cats')
    @UseGuards(RolesGuard)
    export class CatsController {}
    复制代码
  • Method

    @UseGuards(RolesGuard)
    @Delete(':id')
    remove(@Param('id', ParseIntPipe, UserByIdPipe) id: string): Promise<void> {
          return this.usersService.remove(id);
    }
    复制代码
  • Global

    1. 使用useGlobalGuards
    const app = await NestFactory.create(AppModule);
    app.useGlobalGuards(new RolesGuard());
    复制代码
    1. 使用module
    import { Module } from '@nestjs/common';
    import { APP_GUARD } from '@nestjs/core';
    
    @Module({
    providers: [
      {
        provide: APP_GUARD,
        useClass: RolesGuard,
      },
      ],
     })
     export class AppModule {}
    复制代码

设置角色

我们的RolesGuard可以工作了,但是它还未利用最重要的Guards功能—— execution context(执行上下文)。它还不了解每个处理程序允许哪些角色访问。例如,对于不同的路由,CatsController可能具有不同的权限方案。有些可能仅对管理员用户可用,而另一些可能对所有人开放。 我们如何将角色与路由匹配?

这里就要说到自定义元数据了解更多)了。Nest提供了通过@SetMetadat()装饰器将自定义元数据附加到路由的功能。该元数据提供了我们缺少的角色数据。 让我们看一下使用@SetMetadata()的方法:

@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
复制代码

我们将角色元数据(roles是元数据key,['admin']是value)附加到create()方法。直接在路由里使用@SetMetadata()不是好的写法,我们可以将其封装成一个@Role()装饰器。

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

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
复制代码

然后在Controller里面:

@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

复制代码

实现基于角色的Guards

根据路由的元数据角色信息,我们在Guards里判断用户是否有权进入路由:

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

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>{
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const hasRole = () =>
      user.roles.some(role => !!roles.find(item => item === role));

    return user && user.roles && hasRole();
  }
}
复制代码

通过request.user获取用户信息,是因为这里我们假设应用里已经做了Token验证并且上下文存储了用户信息,这一般是每个应用都必不可少的事情。

有关以上下文相关的方式使用Reflector的更多详细信息,请参见Execution context反射和元数据部分。

这样的话,如果用户不是admin角色,会收到下面的响应:

{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}
复制代码

框架默认使用ForbiddenException抛出异常,当然,如果你不想用默认的,你也可以在Guards里抛出你自己的异常,比如:

throw new UnauthorizedException();
复制代码

Interceptors拦截器

拦截器是干嘛的?

  • 在方法执行之前/之后加入我们自己的逻辑
  • 转换/包装一个方法返回的结果
  • 转换/包装一个方法抛出的异常
  • 扩展基础方法的内容
  • 根据某些特殊情况(比如缓存的目的)完全覆盖/重写一个方法

使用范围

  • Controller
    @UseInterceptors(LoggingInterceptor)
    export class CatsController {}
    复制代码
  • Method
     @UseInterceptors(LoggingInterceptor)
     @Get()
     async findAll(): Promise<Cat[]> {
       return this.catsService.findAll();
     }
    复制代码
  • Global
  1. 使用useGlobalInterceptors
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
复制代码
  1. 使用任意Module
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
})
export class AppModule {}
复制代码

Custom Decorators自定义装饰器

什么是装饰器

Nest是围绕一种称为装饰器的语言功能构建的。装饰器的概念可以简单总结为:

ES6装饰器是一个返回函数的表达式,可以将目标,名称和属性描述作为参数。 您可以通过在装饰器前面加上@字符来应用它,并将其放在要装饰的内容的最顶部。 内容可以为类,方法或属性。

如何自定义装饰器

当装饰器的行为取决于某些条件时,可以使用data参数将参数传递给装饰器的工厂函数。 一个常用的场景是自定义装饰器,该装饰器可以通过键从请求对象中提取属性。 例如下面通过传递username字段来获取值,如果User对象比较复杂的话,这样可以更简易的获取数据,可读性也更好。

user.decorator.ts

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

export const User = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    return data ? user && user[data] : user;
  },
);
复制代码

user.controller.ts

 @Get('username')
 async findOne(@UserDecorator('username') username: string) {
       return `Hello ${username}`;
}
复制代码

和管道一起使用

Nest对自定义装饰器和内置的待遇一样,我们可以同内置的一样使用管道验证等。

@Get()
async findOne(@User(new ValidationPipe()) user: UserEntity) {
  console.log(user);
}
复制代码

组合装饰器

Nest提供了一种辅助方法来组成多个装饰器。例如,假设你要将与身份验证相关的所有装饰器组合到一个装饰器中。可以通过以下方式实现:

import { applyDecorators } from '@nestjs/common';

export function Auth(...roles: Role[]) {
  return applyDecorators(
    SetMetadata('roles', roles),
    UseGuards(AuthGuard, RolesGuard),
    ApiBearerAuth(),
    ApiUnauthorizedResponse({ description: 'Unauthorized"' }),
  );
}
复制代码

然后在Controller里面:

@Get('users')
@Auth('admin')
findAllUsers() {}
复制代码

findAllUsers现在就具有通过一个声明应用四个装饰器的效果。

其实经过Nest框架作者Kamil Mysliwiec的精心设计,Nest使用起来非常简单方便,而且扩展是相当的灵活和强大。但是我们必须理解Nest的这些概念是用来干嘛的,这样我们才知道什么时候去实现一个Filter,什么时候去实现一个Pipe。或者知道Middleware和Guards的区别。尤其是Interceptor,好像它哪里都可以使用。如果不好好理解官方的文档,很容易使用不当。

后面我会更新更多关于Nest的使用技巧,比如如何实现JWT Token验证,如何设计环境变量/全局数据注册,或者其他有趣的知识。

旧时茅店社林边,路转溪桥忽见。很高兴认识你,陌生人。