认识 NestJS 的核心模块(基础)

461 阅读2分钟

介绍

Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。如果你正在寻找一个成熟的 TypeScript 框架,那它不可否认是目前最值得推荐的框架之一。

🔍 通常,我们在考虑要不要使用一个框架时,会从几个方面去考量。

  • 📌 框架的热度,通常表现为 github start 数量。

  • 📌 框架的生态/社区是否完善。

  • 📌 框架的维护是否持续,出现的问题是不是有及时修复。

📈 结合上面几个维度,我们针对 Nest 来分析:

  • 💚 Nest Github Start 65k, 热度次于 Express ,排行第二。

  • 💚 Nest 底层使用了 Express(默认)和 Fastify,能轻松使用每个平台的第三方模块,意味着同时拥有了 Express 社区和 Fastify 社区。

  • 💚 Nest Github Issues open 才 53 个,已关闭了 5222 个!平均每月一个版本!而且每个打开的 Issues 都有相应的回复。说明其维护响应速度是相当的快。

在开始学习之前,如果你还没看过 学习 Nestjs 前,你需要了解什么是依赖注入(原理详解) - 掘金,强烈建议你先看一下。依赖注入是 NestJs 最核心的实现,了解其原理学习起来更容易理解。

安装

使用 Nest CLI 构建项目。

npm i -g @nestjs/cli
nest new project-name

目录结构

nest 最核心的就是 控制器提供者模块。一个应用会有一个 APP 模块,而 App 模块中又包含多个功能子模块,每个子模块又由多个控制器提供者组成。目录结构如下:

src
├── cats ------------------------ 模块文件夹
│    ├──dto --------------------- 数据传输对象(http 参数)
│    │   └──create-cat.dto.ts
│    ├── interfaces ------------- 接口 (数据库 / http 返回参数)
│    │       └──cat.interface.ts
│    ├──cats.service.ts --------- 服务
│    └──cats.controller.ts ------ 控制器
     └──cats.module.ts ---------- 模块
├──app.module.ts ---------------- App 模块
└──main.ts

控制器 Controllers

控制器负责处理传入的请求和向客户端返回响应。也是路由创建和处理的地方。

使用 @Controller() 创建一个控制器

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    // 调用服务创建
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    // 调用服务查找
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(@Param() params): string {
    console.log(params.id);
    return `This action returns a #${params.id} cat`;
  }
}

如上我们创建了一个 CatsController 控制器,当客户端使用 GET 请求 /cats 时,就会调用 findAll 方法,这里方法名可以是任意的,主要和方法的装饰器有关。@Get() 指定了该方法接收 GET 请求。同理:当客户端使用 POST 请求 /cats 时,会调用 create 方法。

@Get(':id') 装饰器里的参数可以是子路由,表示 /cats/:id/。

@Redirect('https://nestjs.com', 301) 可以重定向

更多的装饰器请参考:Documentation | NestJS - A progressive Node.js framework

提供者(服务) Providers

为控制器提供各种服务,如创建、编辑、删除等业务逻辑。以及与数据库层面的交互。

使用 @Injectable() 注册一个服务

import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

通常我们会在这里一下数据库的 CRUD 操作。

在对应控制器中使用该服务

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    // 调用服务创建
    this.catsService.create(createCatDto);
  }
}

模块 Modules

每一个 nest 应用都有一个 root 模块,也就是下图中的 Application 模块。模块的功能是去组织的你的组件,维护项目的依赖关系。当然,除了 root 模块,其他模块并不是必须的,但还是强烈推荐使用模块去管理你的项目。

模块可以将多个控制器、提供者、管道、中间件等组织在一起,并可以在其他模块中引用和重复使用。

模块的共享

每一个模块都有自己的作用域(私有域), 所以不同模块之间是隔离的,这就意味着没有引入模块,就不能使用模块上的服务,所有要通过 exports 配合 imports 才能实现共享。

@module({
    imports: [], // 引入模块服务
    exports: [], // 导出模块服务
    providers: [UserService], // 服务
    controllers: [UserController], // 控制器
})
export class UserModule {

}
  1. 共享的是模块上的所有服务。
  2. imports 导入模块、exports 导出服务。引入模块,实际是引入的模块上已导出的所有服务。

例如 Animal模块要使用Cat模块 的服务,Cat模块首先要将自己的服务导出,Animal模块 再引入Cat模块

// ----------- cat.module.ts(导出服务)  --------------------
@Module({
  providers: [CatService1, CatService2],
  controllers: [CatController],
  exports: [CatService1], // 导出服务
})
export class CatModule {}


// ----------- animal.module.ts(引入模块) --------------------
@Module({
  imports: [CatModule], // 引入模块
  providers: [AnimalService1],
  controllers: [AnimalController],

})

export class AnimalModule {}

// ----------- animal.controller.ts(使用共享服务) ----------------
import { CatsService } from '../cats/cats.service'
@Controller('animal')
export class AnimalController {
  // 使用共享服务
  constructor(private catsService: CatsService) {}
}

注意:模块B 想要使用 模块A 的服务,有两个必要条件

步骤一:模块A 导出 了该服务

步骤二:模块B 导入了模块A

如果没满足上面两个条件,模块 A 直接使用 模块B 的服务,会报错:

Error: Nest can't resolve dependencies of the CatsController (?). 
Please make sure that the argument ShareService at index [0] 
is available in the CatsModule context.

全局共享

有时候我们定义了一个模块,其他模块想使用这个模块的服务,可以手动引入,而另一种方式就定义为 @Global 装饰器,使模块成为全局作用域。其他模块就可以无需导出,而使用服务。

不建议这么用,imports 会使模块依赖更加透明。

// ----------- cat.module.ts --------------------
@Global()
@Module({
  providers: [CatService1, CatService2],
  controllers: [CatController],
  exports: [CatService1], // 导出服务
})
export class CatModule {}

// ----------- animal.module.ts --------------------
@Module({
  // imports: [CatModule], // 引入模块
  providers: [AnimalService1],
  controllers: [AnimalController],

})
export class AnimalModule {}

中间件 Middleware

作用于请求到请求处理函数之间。可以在路由处理函数前做一些处理,例如日志记录、身份验证和授权等。

中间件绑定在模块上

图1

应用中间件

nest 的中间件分为两种,class 中间件函数中间件。需要配置中间件的作用范围。

// ----------------------- logger.middleware.ts ---------------
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

// class-中间件
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}
// 函数-中间件
export function logger(req, res, next) {
  console.log(`Request...`);
  next();
};

// ----------------------- app.module.ts ---------------
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('cats'); 
  }
}

上面定义了一个 LoggerMiddleware中间件,在 app 模块中注册,并指定了中间件应用的路由范围为 cats, 那么当请求进入 '/cats/...' 就会进入中间件,那么我们可以在该中间件中记录一些请求和返回体信息。

全局中间件

如果我们想一次性将中间件绑定到每个注册路由,可以在 main.ts 中 create 后的 app 实例中注册。

// ----------------------- main.ts ---------------
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

异常过滤器 Exception filters

作用:负责处理整个应用程序中抛出的异常,使客户端能收到友好的响应提示。

例如:后端系统程序错误,接口返回 500 ,并提示 Internal server error。

内置异常-写法1

开发中常规的异常处理器,NestJs 已经帮我们内置好了,只需要会用就行。例如用户未鉴权,调用接口我们一般会返回一个 403 的状态了,一段提示 。

// ----------------------- cats.controller.ts ---------------
import { HttpException, HttpStatus } from '@nestjs/common'

@Get()
async findAll() {
  if (notLogin) { // 这里是伪代码,仅说明使用场景
    throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
  }
  return ['数据']
}

响应结果:

{
    "statusCode": 403,
    "message": "Forbidden"
}

内置异常-写法2

HttpException 构造函数第一个参数除了是字符串,也可以接收一个对象,就可以自定义异常响应结果的内容。如下:

import { HttpException, HttpStatus } from '@nestjs/common'

@Get()
async findAll() {
  if (notLogin) { // 这里是伪代码,仅说明使用场景
    throw new HttpException({
      success: false,
      status: HttpStatus.FORBIDDEN,
      erro: '未登录,请先登录!'
    }, HttpStatus.FORBIDDEN);
  }
  return ['数据']
}

响应结果:

{
  "success": false,
  "status": 403,
  "error": "未登录,请先登录!"
}

更多内置异常处理器

HttpException 属于核心异常处理器,下面还有一些内置的继承的可用异常处理器。

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableException
  • InternalServerErrorException
  • NotImplementedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException

自定义异常

大多数情况下,内置异常是够用的,如果确实需要创建自定义异常,操作如下:

// forbidden.exception.ts
export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

自定义 ForbiddenException 必须继承 HttpException

使用

import { ForbiddenException } from '/forbidden.exception.ts'
@Get()
async findAll() {
  throw new ForbiddenException();
}

管道 Pipe

管道作用于客户端和控制器处理之前,可以简单理解为是对请求参数的校验和操作。

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

上的代码,ParseIntPipe 是一个内置管道,会校验 id 参数的类型是否为整型,请求 api/ccc 时,由于 ccc 不是一个整型,就会返回异常。也就不会继续执行 findOne 方法。

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

如果我们想自定义响应内容,可以改成:

new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })

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

@nestjs/common 中的内置管道:

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

守卫 guards

绑定在控制器上

守卫的功能类似于中间件,区别在于:

1、守卫是绑定在控制器上,中间件是绑定在模块上。因为模块上可能有很多控制器 ,固然该模块上的中间件就能作用到这些控制器。而守卫控制器是一一绑定的。正因为如此,“守卫比中间件更了解控制器” ,能读取控制器的上的更多信息,而这是中间件做不到的。

2、它们的职责不同,中间件流转在控制器之间,而守卫是控制器的最后一道防卫。

3、守卫执行时机:在中间件之后、拦截和管道之前执行。

比喻:

如果把中间件和守卫比做足球比赛中的后卫和守门员,把中间件比作球门。中间件游走在各个足球传递的中间,只需要记录或者阻止 A-B, 而守门员则是控制器的最后一道防线,防止足球进入球门。

示例:

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

// 注册守卫
@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

// ----------------------- cats.controller.ts ---------------

// 绑定守卫
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

每个守卫必须实现一项canActivate()功能。该函数应返回一个布尔值

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

通过 canActivate 的 context参数,我们可以拿到执行下文的一些信息。例如定义不同的角色,作用在不同的控制器上,通过守卫获取到不用的角色,然后处理。

// 定义元数据
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();

// 在控制器 create 方法上绑定元数据
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

// 守卫中获取下文执行的元数据信息
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // 获取下文中执行方法的角色元数据信息
    const roles = this.reflector.get(Roles, context.getHandler());

    if (!roles) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}

上面,使用 Reflector.createDecorator 定义了元数据信息,并绑定到控制器的 create 方法上,然后在守卫里通过 context.getHandler() 获取方法上的元数据信息。如果守卫作用在控制器上,可以通过 context.getClass() 执行下文的控制器类。

拦截器 Interceptors

它们用于在函数执行之前或之后增加额外的逻辑

  • 转换函数返回的结果

  • 转换函数抛出的异常

  • 绑定额外的逻辑到方法的入口和出口:这允许我们在请求处理流程中的特定点执行某些操作,例如,记录日志、计算方法执行时间等。

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

// 定义拦截器
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');

    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`After... ${Date.now() - now}ms`)),
      );
  }
}

// 绑定拦截器
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

案例:

  • 返回结果是 null 转成 '' 空字符串。

  • 返回结果缓存起来,如果请求参数不变,返回缓存结果。

  • 请求超时后自定义返回结果。

流操作:

由于handle()返回 RxJS Observable,我们有多种操作符可供选择来操作流。

  • tab() :流正常或异常终止时调用,不会以其他方式干扰响应周期。

  • map(): 响应映射,处理返回结果。

  • catchError(): 异常映射,处理抛出异常

总结

本文从项目结构上解释了 NestJS 中的核心概念。帮助大家总整体去理解 NestJS 开发运作模式。我们可以严格按照官方的规范走,这会帮我们省下不少时间。