Nest学习笔记

前言

学习Nestjs更多的处于兴趣,公司项目中有使用nest,从代码结构看感觉跟springboot的写法类似,我也在自己的部分demo项目中应用了Nestjs+express作为框架进行开发,因此开始学习Nest的相关知识

介绍

在学习Nestjs之前需要有一定的TypeScript的使用或学习经验,虽然Nest支持使用原生纯js进行书写,但是官方示例以及cli工具生成的代码都是ts的,笔记中的相关代码部分我也会应用ts进行记录。

支持版本:Node.js(>= 10.13.0, v13版本除外)

  • 版本查看命令如下:
1
2
3
4
$ node -v
v14.17.3
$ npm -v
v7.x.x

注:如果电脑没有node环境,或没有node相关基础请移步node教程

  • 安装Nest Cli工具及创建项目:
1
2
npm i -g @nestjs/cli
nest new project-name
  • 通过cli创建的项目中包含以下核心文件:
    src
    |- app.controller.spec.ts (对于基本控制器的单元测试样例)
    |- app.controller.ts (带有单个路由的基本控制器示例)
    |- app.module.ts (应用程序的根模块)
    |- app.service.ts (带有单个方法的基本服务)
    |- main.ts (应用程序入口文件。它使用 NestFactory 用来创建 Nest 应用实例)

  • 作为渐进式框架兼容express:
    创建应用时可以直接指定需要使用的HTTP平台

1
const app = await NestFactory.create<NestExpressApplication>(AppModule);
  • 运行方式npm run start

Controller

Controller主要用于通过分组路由对传入的请求进行处理并向客户端返回响应

创建与路由

cli快捷命令:nest g controller project-name

注解@Controller()中存在可选参数路由前缀,可以通过设置路由前缀对文件中的路由进行分组,例如@Controller(index)必须通过localhost:3000/index来请求,这样就不需要在内部方法中重指定路由前缀了,官方示例:

1
2
3
4
5
6
7
8
9
10
11
12
/* cats.controller.ts */

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}

路由路径:用于指定请求的处理方法,可以通过/路由前缀/装饰器路由信息的方式生成路由映射

装饰器:即@Get()@Post()等在内的HTTP请求方法装饰器,用于指定端点对应的HTTP请求方法

路由中支持使用通配符*用于匹配任何字符组合即字符 ?+* 以及 () 是它们的正则表达式对应项的子集。连字符- 和点.按字符串路径逐字解析。

1
2
3
4
@Get('ab*cd')
findAll() {
return 'This route uses a wildcard';
}

上述路由可以匹配abcdab_cdabecd

Request

Nest在提供了一些方案来处理请求细节,它提供了对于请求对象(request)的访问方式,可以通过处理函数中的@Req()装饰器,指示Nest将请求对象注入处理程序,当然在正常的程序书写中,我们也不会直接去处理整个request对象相反我们更多的是处理HTTP headerHttp body的属性,因此Nest也提供了@Body@Query等可以开箱即用的装饰器
详细对照请参考()[https://docs.nestjs.cn/9/controllers]

1
2
3
4
5
6
7
8
9
10
@Controller('index')
export class AppController {
constructor(private readonly appService: AppService) {}

@Get('getHello')
getHello(@Req() req: Request): string {
console.log('req: ', req);
return 'helo';
}
}

请求中我们总会处理各种状态码,可以通过在处理函数外添加@HttpCode()注解指定需要的状态码
我们可以使用@Header()来指定自定义的响应头

如果在一些特定情况下需要将响应重定向到指定的URL(例如404的时候),可以使用@Redirect()制定需要的URL
@Redirect()中有两个可选参数,一个就是url,另一个是statusCode,如果省略第二个参数则会默认使用302状态码

当我们在请求处理中需要接收动态数据的时候@Param可以通过下述两种方式进行获取所需的路由参数,访问方法为GET /cats/1

1
2
3
4
5
6
7
8
9
10
@Get(':id')
findOne(@Param() params): string {
console.log(params.id);
return `This action returns a #${params.id} cat`;
}

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

异步数据

每一个异步函数都必须返回一个Promise,因此Nest支持使用异步函数(Async / await),Nest会自动解析这个被返回的Promise

请求负载

POST请求中我们可以使用@Body()获取请求传入的参数,如果应用Typescript则需要确定DTO(数据传输对象),它定义了需要数据的格式,可以通过ts的interfaceclass来定义DTO模式,Nest更推荐使用class来定义

1
2
3
4
5
6
7
8
/*
* create-demo.dto.ts
*/
export class CreateDemoDto {
readonly name: string;
readonly age: number;
readonly breed: string;
}

通过定义可以直接在下述DemoController中通过不同的两种方式使用创建的DTO:

1
2
3
4
5
6
7
8
9
10
11
12
/* demo.controller.ts */

@Post()
async create(@Body() createDemoDto: CreateDemoDto) {
return 'This action adds a new demo';
}

@Post()
@Bind(Body())
async create(createCatDto) {
return 'This action adds a new demo';
}

注册控制器

当Controller已经准备就绪后,我们需要告知Nest该Controller的存在,而Controller总是属于Module的,所以我们可以看到@Module()装饰器中包含了controllers数组

因此可以通过以下方式进行定义:

1
2
3
4
5
6
7
8
9
/* app.module.ts */

import { Module } from '@nestjs/common';
import { DemoController } from './demos/demo.controller';

@Module({
controllers: [DemoController],
})
export class AppModule {}

Provide

CLI快捷命令:$ nest g service cats

Provide是一个使用@Injectble()装饰器注释的类,它的设计理念源于控制反转(IOC)中依赖注入的特性

Nest 的分层借鉴自 Spring,更细化。随着代码库的增长 MVC 模式中 Modal 和 Controller 会变得含糊不清,导致难于维护。

Service

先从一个服务开始了解Provide,先创建一个简单的负责数据存储与检索的服务DemoService开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*  cats.service.ts  */

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;
}
}

/* cats.controller.ts */
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@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();
}
}

CatsService 是通过类构造函数注入的。注意这里使用了私有的只读语法。这意味着我们已经在同一位置创建并初始化了 catsService 成员。

上面示例中constructor(private catsService: CatsService) {}就是依赖注入的一种体现,
NestcatsService创建后会返回一个实例来解析CatsService

Provider通常拥有与主体程序同步的生命周期,即作用域

constructor的参数中可以使用@Optional()标明参数可选

对于依赖注入不仅仅只有上述通过构造函数注入的方式,但是如果一个顶级类需要依赖于一个或多个providers时这种方式就会变得很麻烦,因此这时我们可以使用基于属性的注入,即应用@Inject()装饰器

1
2
3
4
5
6
7
import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
@Inject('HTTP_OPTIONS')
private readonly httpClient: T;
}

注意:如果您的类没有扩展其他提供者,你应该总是使用基于构造函数的注入。

当我们在controller中注册了服务的使用者以后,我们需要在Nest中注册这个服务,以便于其可以正确地执行注入

我们可以编辑模块文件app.module.ts,然后将服务添加到@Module()装饰器的providers数组中。

Module

CLI快捷命令:$ nest g module cats

Module是一个具有@Module装饰器的类,Nest通过Module形成了整体的应用程序结构

每一个Nest程序至少有一个Module,被称为根模块,当应用程序比较小的时候它就应该是唯一的模块

@Module()装饰器可以接收一个描述模块属性的对象:

jsx
1
2
3
4
5
6
7
8
9
10
11
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
controllers: [CatsController], // 必须创建的一组控制器
providers: [CatsService], // 由Nest注入实例化的提供者
imports: [CommonModule], // 导入模块的列表,这些模块导出了此模块中所需provider
exports: [CatsService] // 由本模块提供并可以在其他模块中应用的的providers
})
export class CatsModule {}

当我们将子模块导出后可以直接在根模块ApplicationModule中进行导入

实际上每个模块都是一个共享模块,一旦创建就能被任意的模块重复使用,当我们需要在几个模块之间共享一个Service实例,那么我们只需要将该Service放到exports数组中即可
之后每个导入此Module的模块都可以直接访问该`Service

provider可以通过依赖注入写入Module中但是不能反过来将Module注入provider

全局模块

有些模块可能由于一些原因需要封装一些公用的东西(例如数据库连接、helper等),那这个时候讲究需要用到全局模块

jsx
1
2
3
4
5
6
7
8
9
10
11
import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Global()
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}

@Global装饰器可以使模块编程全局作用于,而全局模块只需要在核心模块中注册一次,如此则不需要再在其他需要使用该全局模块的地方再次引入了

动态模块

动态模块是Nest中的一个强大功能,这些模块可以动态注册和配置提供程序,下面是官方提供的一个动态模块DatabaseModule的定义示例:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 方法1
import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
providers: [Connection],
})
export class DatabaseModule {
static forRoot(entities = [], options?): DynamicModule {
const providers = createDatabaseProviders(options, entities);
return {
module: DatabaseModule,
providers: providers,
exports: providers,
};
}
}

// 方法2
import { Module } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
providers: [Connection],
})
export class DatabaseModule {
static forRoot(entities = [], options?) {
const providers = createDatabaseProviders(options, entities);
return {
module: DatabaseModule,
providers: providers,
exports: providers,
};
}
}

forRoot函数可以返回一个动态模块,这个模块可以使同步的也可以是异步的

上述方式通过默认的方式封装了一个Connectionprovider,此外还通过将一个包含entitiesoptions的对象传入forRoot()方法中(类似一个存储库)
动态模块返回的属性扩展(非覆盖方式)了通过@Module装饰器定义的基础模块的元数据,这就是通过模块导出静态声明的Connection提供程序和动态生成的存储库provider的方式

如果需要再全局范围内注册动态模块则需要将上述return中的global属性设置为true

对于上述DatabaseModule可以被导入,导入方式如下:

jsx
1
2
3
4
5
6
7
8
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
imports: [DatabaseModule.forRoot([User])],
})
export class AppModule {}

如果需要重新导出动态模块则可以省略forRoot方法调用

jsx
1
2
3
4
5
6
7
8
9
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
imports: [DatabaseModule.forRoot([User])],
exports: [DatabaseModule],
})
export class AppModule {}

中间件(Middleware)

中间件是一个在路由跳转之前调用的函数,这个函数可以访问requestresponse对象,以及应用程序的请求响应周期中的next()中间件函数

这里的中间件实际上与Express中的中间件相同

中间件函数可以执行如下任务:

  • 执行任何代码
  • request/response对象进行修改
  • 结束请求/响应周期
  • 调用堆栈中的下一个中间件函数
  • 如果在这个中间件函数中没有对请求/响应周期进行终止则需要调用next()方法传递到下一个中间件函数,否则这个请求/响应将被挂起

定义一个中间件函数应该在其具有@Injectable()装饰器的类中实习NestMiddleware接口,如果是一个函数则没有特殊要求

jsx
1
2
3
4
5
6
7
8
9
10
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中列出,我们必须使用模块类的configure()方法来进行设置,且必须实现NestModule接口

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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');
}
}

在我们对中间件进行配置时可以将包含路径的对象和请求方法传入forRoutes()方法,这样可以进一步地将中间件限制为特定的请求方法

如下导入RequestMethod来引用所有需要的请求方法类型

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Module, NestModule, RequestMethod, 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({ path: 'cats', method: RequestMethod.GET });
}
}

路由通配符

在中间件的路由匹配中支持模式匹配,通配符的使用和正则类似,例如*可以匹配任意字符,例如下方示例:

jsx
1
forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

消费者

forRoutes()不仅仅可以接收字符串、对象还能接收一个甚至多个控制类,大多数情况下都会传入一个控制器列表

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller.ts';

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

如果在应用中间件中想要排除一些路由可以使用exclude()方法进行操作,这个方法参数可以是一个字符串、多个字符串或者一个RouterInfo对象

jsx
1
2
3
4
5
6
7
8
consumer
.apply(LoggerMiddleware)
.excude(
{ path: 'cats', method: RequestMethod.GET },
{ path: 'cats', method: RequestMethod.POST },
'cats/(.*)',
)
.forRoutes(CatsController);

函数中间件

从上面的介绍中可以知道LoggerMiddleware类非常简单,实际上他可以只使用一个简单的函数而这种类型我们称为函数式中间件

jsx
1
2
3
4
export function logger(req, res, next) {
console.log(`Request...`);
next();
}

定义后可以直接在appModule中进行使用

jsx
1
consumer.apply(logger).forRoutes(CatsController);

上面我们也提到了可以同时引入多个中间件,方法如下

jsx
1
consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);

全局中间件

我们可以使用INestApplication实例中提供的use()方法直接将中间件绑定到所有的已注册路由

jsx
1
2
3
4
5
6
import {NestFactory} from "@nestjs/core";
import {AppModule} from "./app.module";

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

异常过滤器

Nest中提供了内置的异常层,开箱即用,这个异常处理操作由内置的全局异常过滤器负责,该过滤器可以处理HttpException的异常,如果异常无法被识别时,用户将收到如下`JSON

1
2
3
4
{
"statusCode": 500,
"message": "Internal server error"
}

基础异常

Nest中有一个内置的HttpException类,最常用的就是在发生错误时发送标准的HTTP响应对象

在如下示例中会模拟一个异常

1
2
3
4
@Get()
async findall() {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

当触发请求时客户端就会收到如下响应

1
2
3
4
{
"statusCode": 403,
"message": "Forbidden"
}

HttpException构造函数有两个必要参数:

  • response参数是一个JSON响应体
  • status定义HTTP状态码

JSON响应体包含两个属性:

  • statusCode:状态码
  • message:基于状态的错误描述

一般情况下我们不需要编写自定义异常,如果确实需要创建自定义异常则要创建自己的异常层次结构

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
import {Get} from "@nestjs/common";

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

// 可以像下面这样使用上述自定义异常
@Get
async findAll() {
throw new ForbiddenException();
}

异常过滤器

异常过滤器可以赋予开发者对于异常层完全控制权

异常过滤器可以用来捕获作为HttpException类实例的异常并设置自定义的响应逻辑,因此我们需要应用RequestResponse对象来进行我们想要的操作

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

所有的异常过滤器都必须实现通用的ExceptionFilter<T>接口

在过滤器编写完成后需要将过滤器绑定到controllercreate()方法上

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
// 方法1
@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException();
}

// 方法2
@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException();
}

上述两种在实际使用时更推荐使用类而非使用实例,这样可以减少对内存的占用

异常过滤器的作用域可以划分为三个级别:方法范围、控制器范围以及全局范围

方法范围和控制器范围都是在相关方法或Controller上配置@UseFilters(),而全局范围的配置可以如下使用:

jsx
1
2
3
4
5
6
7
8
import {NestFactory} from "@nestjs/core";

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

通过上述useGlobalFilters()方法配置时不会为网关和混合应用程序设置过滤器

下面这种办法可以注册一个全局范围的过滤器

jsx
1
2
3
4
5
6
7
8
9
10
11
12
import { Module } from "@nestjs/common";
import { APP_FILTER } from '@nestjs/core';

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

在编写异常过滤器时使用@Catch()装饰器列表为空则默认捕获每一个未处理的异常

继承

通常我们将创建完全定制化的异常过滤器,如果想要重用一个已经实现了核心逻辑的异常过滤器,并重写部分方法,那么就需要用到继承

jsx
1
2
3
4
5
6
7
8
9
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
catch(exception: unknown, host: ArgimetsHost) {
super.catch(exception, host);
}
}

Pipes(管道)

Pipes是具有@Injectable()装饰器的类,Pipes应该实现PipeTransform接口。

通常pipes会处理来自控制器路由处理程序的参数,Nest会在调用这个方法之前插入一个管道,管道会先拦截方法的调用参数,进行预处理

Nest中有很多开箱即用的内置管道,下面会有将内置pipes绑定在controller上的示例

当管道内抛出异常时controller将不会继续执行

内置管道

Nest中一共有九个开箱即用的管道:

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

以上的几个管道都是从@nestjs/common包中导出的

下面将通过ParseIntPage来演示一下转换的应用场景

绑定管道

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

这样就能确保findOne()方法所接受的参数是一个数字,且在路由处理程序呗调用之前就被处理并抛出异常

GET localhost:3000/abc
这样请求后Nest就会放回相关异常

1
2
3
4
5
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}

异常抛出后findOne方法不会被继续执行,上述方法中我们传入的是一个类将实例化的部分交给框架进行处理也就是依赖注入,我们也可以选择传入一个实例

jsx
1
2
3
4
5
6
7
@Get(':id')
async findOne(
@Param('id', new ParsseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
id: number,
) {
return this.catService.findOne(id)
}

同上述操作一样,其他的Parse*管道的使用方式一致

自定义管道

如上文说的Nest提供了很多强大的内置管道,但是有些清关我们也需要使用更加定制化的管道

以一个自定义的简单ValidationPipe为例:

jsx
1
2
3
4
5
6
7
8
import { PipeTranform, Injectable, ArgumentMetadata } from '@nestjs/common';

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

PipeTransform<T, R>是管道必须实现的泛型接口,T是value类型,R是transform()方法的返回类型

transform方法有两个参数valuemetadata,value事当前处理的方法的参数,metadata是当前处理的方法参数的元数据

jsx
1
2
3
4
5
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
  • type:参数是一个body@Body(),query@Query(),param@Param()或其他自定义参数
  • metatype: 数据元类型,如String
  • data传递给装饰器的字符串,例如@Body('string')

应用场景

Pipes有两个典型的应用场景:

  • 转换:将输入数据转换为需要的数据输出
  • 验证:对输入的数据进行验证,如果验证成功则继续传递,否则抛出异常

参考书籍