【Nest.js】简单谈谈Nestjs中的Module和AOP特性

842 阅读11分钟

这篇文章用于记录如何创建一个简单的Nest应用服务。并且讲解了Module 以及几个Nestjs中几个内置的AOP特性,如:MiddleWare中间件Guard守卫Interceptor拦截器Pipe管道以及filter过滤器的相关概念。如有错误,欢迎支持!

相关代码见github

创建一个Nestjs应用服务

我们首先需要做的是以下操作

  • 在全局安装nestjs的命令行工具
npm i -g @nestjs/cli
  • 使用nestjs命令创建一个新的应用
nest new demo1-basic

然后会有一个交互窗口,让你选择对应的包管理器,我这里选择的是yarn

选择完后会得到一个这样的目录结构

demo1-basic
├── README.md
├── nest-cli.json
├── package.json
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock

在这里,main.ts就是我们的程序入口文件。它的内容如下:

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

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

我们通过调用NestFactory.create这个方法,传入一个AppModule创建了一个app实例。

Module

到这里,我们就可以看到Nest中的一个重要概念 Module(模块)。nest以Module为单位组织代码,通过@Module()装饰器来定义模块。

一个Module包含以下内容:

  • imports: 模块依赖其他模块
  • controllers:用于声明当前模块所管理的controller类的数组,比如我们goods.controller.ts中基于@Controller('/goods')装饰的类就是一个controller类,用于管理路由(在请求的路径前加了个前缀,这是是/goods),然后配合@Get@Post这些来处理请求
  • providers: 声明项目中的Provider,将它们注入到其他类中使用。而这里的Provider可以理解为就是一些可重用的JavaScript类和对象。比如通过@Injectable管理的类就可以被注入到其他类中,包括但是不限于Service,我们下面的代码中会有体现,也就是依赖注入(DI)
  • exports:模块对外提供的方法类

AppModule是项目的主模块,但是在一个应用中,通常是包含了很多模块。比如一个商城应用,可能包含用户商品等模块。

这个时候,只写一个AppModule就不适用了。所以针对我们上面的src/的目录结构会改成类似如下:

├── src
│   ├── user
│   │   ├── user.controller.spec.ts
│   │   ├── user.controller.ts
│   │   ├── user.module.ts
│   │   ├── user.service.ts
│   ├── goods
│   │   ├── goods.controller.spec.ts
│   │   ├── goods.controller.ts
│   │   ├── goods.module.ts
│   │   ├── goods.service.ts
│   ├── app.module.ts
│   └── main.ts

我们使用命令行来生成我们需要的文件

  • 生成goods模块
# 生成goods模块的 module 文件
nest generate module goods
# 生成goods模块的 controller 文件
nest generate co goods
# 生成goods模块的 service 文件
nest generate s goods
  • 生成user模块
nest generate module user
nest generate co user
nest generate s user

我们可以看到,每次生成模块都会添加对应的ControllerService。这是因为Controller是对应模块的路由控制器,而Service则通常用于实现与数据存储交互、处理业务逻辑、调用外部 API 或执行其他通用操作。

我们可以在上面代码的基础上修改以下两个文件

goods/goods.controller.ts

import { Controller, Get } from '@nestjs/common';
import { GoodsService } from './goods.service';

@Controller('goods')
export class GoodsController {
  constructor(private readonly goodsService: GoodsService) {}
  @Get()
  findAll() {
    return this.goodsService.findAll();
  }
}

goods/goods.service.ts

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

@Injectable()
export class GoodsService {
  findAll() {
    return [
      { id: 1, name: 'iphone12', price: 2000 },
      { id: 2, name: 'iphone13', price: 3000 },
    ];
  }
}

然后浏览器中访问 http://localhost:3000/goods 就会得到如下输出

image.png

对应到我们上面的代码,当我们访问http://localhost:3000/goods时,首先:

  • 请求进入到了我们写的应用程序中,访问/goods时 被nest 分到了GoodsController
  • 然后通过依赖注入goodsService访问到了Service类中的findAll方法
  • 最终返回了指定的数据

在上面,我们提到了依赖注入,在nestjs中,通过@Injectable()GoodsService标记为一个Provider

然后,我们会在nest的Moduleproviders中注入Provider这一步,我们上面通过命令行生成的时候会自动写入,如果是自己创建文件,别忘记写):

@Module({
  controllers: [GoodsController],
  providers: [GoodsService],
})
export class GoodsModule {}

最后,我们就可以像上面的代码一样,在Controller访问GoodsService这个类对应的方法

中间件

中间件是在路由处理程序之前调用的函数

我们可以看官网中的例子,先创建一个middleware

logger.middleware.ts

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}

使用中间件

中间件的使用无法通过在@Module中注入,它需要通过实现NestModule类中的configure方法:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('goods', 'user');
  }
}

然后在请求loalhost:3000/goodslocalhost:3000/user时就会输出Request

consumer还有更多的配置,这些可以通过官网来看。

函数中间件

上面我们是通过实现NestMiddleware接口的use方法来生成中间件。但是该中间件并没有任何依赖。那么,就可以简单的使用函数中间件来实现上面的日志中间件

import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};

然后在AppModule中使用apply应用中间件,如果是多个,可以按照以下的方式

consumer.apply(cors(), helmet(), logger).forRoutes(GoodsController, UserController);

但是上面有说到,如果有依赖(数据库连接, 配置等),比如要实现鉴权日志入库,这些会连接数据库。那么我们就可以通过实现NestMiddleware的方式来完成创建中间件,因为函数式中间件无法访问到依赖注入或是使用其他服务,或者比较复杂的错误处理

全局中间件

当想要一次性在所有路由上都绑定中间件时,可以通过全局中间件的形式绑定

const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

Guard 守卫

Guard在所有的中间件之后执行,并且在pipe管道过滤器拦截器之前执行,具体信息可以看文档

虽然中间件也可以用作鉴权,但是它只能处理一些简单的场景,比如:根据请求头或者请求参数等基本信息进行简单地鉴定

而对于复杂的鉴权策略,需要考虑用户的角色、权限等复杂信息,需要更加灵活的方案来处理。

在这种情况下,Guard提供了更加灵活和可定制的鉴权策略,可以通过自定义的逻辑实现复杂的鉴权场景,例如基于用户角色或权限、基于请求路径等等。通常,Guard可以处理许多中间件不能处理的复杂情况。

因此,在处理复杂鉴权场景时,Guard是一种更加灵活和可扩展的解决方案,可以提供更高级别的安全性和更好的用户体验。而对于一些简单的鉴权场景,使用中间件也是有效的方案。

写法

  1. 实现CanActive接口中的 canActive方法,并且需要返回一个boolean值,true则表示可以访问,false则拒绝该请求访问
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    // validateRequest就是你自己实现的鉴权方案
    return validateRequest(request);
  }
}
  1. 注册使用
    • 具体到控制器、具体路由方法等
      @Controller('goods')
      @UseGuards(AuthGuard)
      export class GoodsController {}
      
    • 在main.ts中全局注册
      const app = await NestFactory.create(AppModule);
      app.useGlobalGuards(new RolesGuard());
      

拦截器Interceptor

拦截器是一种面向切面(AOP)的编程技术,他有以下功能:

  • 在请求之前/之后绑定额外的逻辑-
  • 转换函数返回的结果
  • 转换函数抛出的异常
  • 扩展基本功能行为
  • 根据特定条件完全覆盖函数(例如,出于缓存目的)

实现

下面实现一个全局拦截器,用于统一处理接口数据的返回格式

  1. 实现NestInterceptor接口的intercept方法,intercept方法有两个参数:第一个是上下文,第二个是CallHandler,并且必须通过它调用handle()方法,路由则无法被执行。
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<Response<T>> {
    console.log('coming ResponseInterceptor interceptor');
    return next.handle().pipe(
      map((data) => ({
        data,
        status: 200,
        extra: {},
        message: 'success',
        success: true,
      })),
    );
  }
}
  1. main.ts全局注册(也可以在指定的路由方法中调用,可以根据具体情况编写不同的拦截器来使用)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new ResponseInterceptor());
  await app.listen(3000);
}

然后我们可以在postman中请求之前的http://localhost:3000/goods,就会发现我们的数据中多了一层通用格式

{
    "data": [
        {
            "id": 1,
            "name": "iphone12",
            "price": 2000
        },
        {
            "id": 2,
            "name": "iphone13",
            "price": 3000
        }
    ],
    "status": 200,
    "extra": {},
    "message": "success",
    "success": true
}

Pipe管道

管道有两个典型的用例:

  • 转换 transformation:将输入数据转换为所需的形式(例如,从字符串到整数)
  • 验证 validation:评估输入数据,如果有效,则简单地通过它;否则抛出异常

在nest中已经有了很多内置的Pipe

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

我们可以模仿ParseIntPipe来自定义一个Pipe

实现

  1. 实现PipeTransform接口的transform方法,然后返回转换后的值。然后通过@Injectable()装饰器将该类注入到nest中
@Injectable()
export class CustomParseIntPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    console.log('coming CustomParseIntPipe');
    if (!this.isNumeric(value)) {
      throw new BadRequestException(
        'Validation failed (numeric string is expected)'
      );
    }
    return parseInt(value, 10);
  }

  isNumeric(value) {
    return (
      ['string', 'number'].includes(typeof value) &&
      /^-?\d+$/.test(value) &&
      isFinite(value)
    );
  }
}
  1. 注册

先在goods/goods.controller.ts中新增加一个方法,用于通过id获取单挑数据,然后通过在@Param使用对应的CustomParseIntPipe即可在请求前完成数据的转换

  @Get(':id')
  findOne(@Param('id', CustomParseIntPipe) id: number) {
    return this.goodsService.findOne(id);
  }

goods/goods.service.ts要添加一个findOne方法

const data = [
  { id: 1, name: 'iphone12', price: 2000 },
  { id: 2, name: 'iphone13', price: 3000 },
];
@Injectable()
export class GoodsService {
  findAll() {
    return data;
  }

  findOne(id) {
    return data.filter((item) => item.id === id);
  }
}

然后在postman中请求http://localhost:3000/goods/1就能获取到正确的数据,如果没有这个CustomParseIntPipe,接收到的id是一个string类型的,则无法找到对应的数据。

还有一个作用就是用来验证数据是否合法,比如如果我请求的是http://localhost:3000/goods/abc,那么在进入到CustomParseIntPipe会通过isNumeric来判断当前的参数是否可以转为数字,如果不行,则直接报错如下信息

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

filter过滤器

虽然基础(内置)异常过滤器可以为你自动处理许多情况,但有的时候,我们会希望对异常层进行完全控制。例如,你可能想添加日志或根据一些动态因素使用不同的 JSON 模式。而异常过滤器可以让我们很好的达成这个目的

HttpException

我们通过自定义创建了一个HttpExceptionFilter过滤器,使用response.json()对返回的json做了修改

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

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

    console.log('into HttpExceptionFilter ....');
    response.status(status).json({
      status,
      timestamp: new Date().toISOString(),
      path: request.url,
      error: name,
      message,
    });
  }
}

@Catch(HttpException)装饰器将所需的元数据绑定到异常过滤器上,告诉Nest这个特定的过滤器正在寻找HttpException类型的异常,而不是其他的。@Catch() 装饰器可以接受一个单一的参数,或者一个逗号分隔的列表。这让我们可以一次为几种类型的异常设置过滤器。

然后通过全局注册这个过滤器即可

  app.useGlobalFilters(new HttpExceptionFilter());

全局异常捕获

在nest中,有一个内置的全局异常过滤器(filter),它处理HttpException类型的异常(以及它的子类)。当一个异常未被识别时(既不是HttpException,也不是继承自HttpException的类),内置的异常过滤器会生成以下默认的JSON响应:

{
  "statusCode": 500,
  "message": "Internal server error"
}

我们可以改造goods/goods.controller.ts中的findOne方法,通过访问一个不存在的属性来人造一个异常。

  @Get(':id')
  findOne(@Param('id', CustomParseIntPipe) id: number) {
    const a: any = {};
    console.log(a.b.c);
    return this.goodsService.findOne(id);
  }

这个异常并不是HttpException类的异常(以及它的子类),所以被内置异常过滤器捕获就会生成以上默认的响应。

不过我们可以通过自定义BaseExceptionFilter来捕获这些非HttpException的异常。 filters/base.exception.filter.ts

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

/**
 * 捕获所有未处理的异常
 */
@Catch()
export class BaseExceptionFilter implements ExceptionFilter {
  catch(exception: Error, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest();
    console.log('into BaseExceptionFilter ....');
    const { name, message } = exception;
    response.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      timestamp: new Date().toISOString(),
      path: request.url,
      error: name,
      message,
    });
  }
}

从上面我们可以看到,该filter实现了ExceptionFilter的catch方法,并对response做了对应的改变,让报错信息更明显。

还有就是为了捕捉每一个未处理的异常(不管异常类型如何),让@Catch()装饰器的参数列表为空,例如:@Catch()

然后,我们再在main.ts中全局注册

  app.useGlobalFilters(new BaseExceptionFilter(), new HttpExceptionFilter());

这次,当我们再次访问http://localhost:3000/goods/1时,就会发现返回的json变成了如下格式, 提示的非常的友好:

{
    "statusCode": 500,
    "timestamp": "2023-04-21T12:52:54.091Z",
    "path": "/goods/1",
    "error": "TypeError",
    "message": "Cannot read properties of undefined (reading 'c')"
}