NestJS 核心技术

Modules 模块

NestJS 使用模块组织应用程序结构,每个模块封装一组密切相关的功能,里面包含依赖的其他模块、路由控制器、事务处理方法等。
每个应用程序至少有一个模块,即根模块。根模块是 Nest 用于构建应用程序图的起点。
模块是由装饰器 @Module() 装饰的类。
在项目中添加一个模块, 如 users:

1
nest g mo users

该命令会在 src 文件夹下创建 users 文件夹,其中包含 users.module.ts 文件。
module 文件应该像下面这样:

1
2
3
4
5
6
7
8
9
import { Module } from "@nestjs/common";

@Module({
imports: [],
controllers: [],
providers: [],
exports: []
})
export class UsersModule {}

@Moudle 的元数据有四个字段,它们起着非常重要的作用:

  1. imports:用来向当前模块导入其他模块。导入其它模块后,其他模块的控制器上下文就被包含在当前模块中,但 Provider 不能被当前模块直接使用,除非在被导入模块中,Provider 被包含在 exports 字段中,否则需要单独导入 Provider 并将其包含在当前模块的 providers 字段中。
  2. controllers:引用控制器,使控制器作用于当前模块。
  3. providers:将包含在其中的 Provider 注入到控制器中,以便控制器能直接使用它们。
  4. exports:导出 Provider,使导入该模块的模块可直接使用包含在其中的 Provider。

    Controllers 控制器

    控制器负责注册路由,处理传入的请求并将响应返回给客户端。
    控制器是由装饰器 @Controller() 装饰的类。
    在项目中添加一个控制器,如 users:
    1
    nest g co users
    该命令会在 users 文件夹中创建 users.controller.ts 文件。
    controller 文件应该像下面这样:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import { Body, Controller, Get, Post, Query } from "@nestjs/common";
    import { UserDto } from "./user.dto";

    @Controller("users")
    export class UsersController {
    @Get("list") // path = /users/list
    getList(@Query("page") page: number, @Query("limit") limit: number) {
    return "分页";
    }

    @Post("add") // path = /users/add
    addUser(@Body() user: UserDto) {
    return "添加用户"
    }
    }
    @Controller 注册模块的根路由,@Get@Post 等方法注册子路由。

    获取请求参数

    从 url 获取参数
    Get 方法的参数拼在路径后面,用 & 分隔,如 /users/list?page=1&limit=10,获取这种参数可使用 @Query 装饰器:
    1
    2
    3
    4
    5
    6
    7
    @Controller("users")
    export class UsersController {
    @Get("list")
    getList(@Query("page") page: number, @Query("limit") limit: number) {
    console.log(page, limit); // "1" "10"
    }
    }
    由于网络传输的都是字符串。因此这里获取到的 page 和 limit 都是 string 类型,想要将其转换成 number 类型,可直接使用管道 ParseIntPip
    修改代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { ParseIntPipe, UsePipes, Get, Query } from "@nestjs/common";

    @Controller("users")
    export class UsersController {
    @Get("list")
    getList(@Query("page", ParseIntPipe) page: number, @Query("limit", ParseIntPipe) limit: number) {
    console.log(page, limit); // 1 10
    }

    // 或者
    @Get("list")
    @UsePipes(ParseIntPipe)
    getList(@Query("page") page: number, @Query("limit") limit: number) {
    console.log(page, limit); // 1 10
    }
    }
    从 body 获取参数
    通过 @Body 装饰器来获取 body 中的参数。
    application/json
    application/json 是传输文本最常用的方式,代码入下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { Body, Controller, Post } from "@nestjs/common";
    import { UserDto } from "./user.dto";

    @Controller("users")
    export class UsersController {
    @Post("add")
    addUser(@Body() user: UserDto) {
    console.log(user); // { name: "xxx", age: "20", sex: "male" }
    }
    }
    UserDto 是一个数据传输对象(DTO,data transfer object),它是一个类,代码如下:
    1
    2
    3
    4
    5
    6
    7
    export class UserDto {
    name: string;

    age: number;

    sex: "male" | "female";
    }
    其实,用接口声明类型也是可以的,但如果要用管道(Pipes)的话,就必须使用类,因为管道会在运行时访问变量的元类型,而接口在编译过程中就被删除了。
    开启 ValidationPipe 的 tranform 功能后,user 会自动被转换为 UserDto 对象:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
    import { UserDto } from "./user.dto";

    @Controller("users")
    export class UsersController {
    @Post("add")
    @UsePipes(new ValidationPipe({ transform: true }))
    addUser(@Body() user: UserDto) {
    console.log(user); // UserDto { name: "xxx", age: "20", sex: "male" }
    }
    }
    multipart/form-data
    multipart/form-data 类型常用来上传文件。假如现在前端要上传这样一个 FormData:
    1
    2
    3
    const formData = new FormData();
    formData.append("file", new Blob(["123456"]), "xxx.txt");
    formData.append("name", "Silence");
    后端该如何接收呢?
    为了处理文件上传,Nest 提供了一个基于 Express 的 multer 中间件包的内置模块。Multer 处理以 multipart/form-data 格式上传的数据。
    为了更好的类型提示,安装 Multer typings 包:
    1
    npm i -D @types/multer
    此外,还需要用到 FileInterceptor 拦截器,将在下面介绍。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { Body, Controller, Post, UploadedFile, UseInterceptors } from "@nestjs/common";
    import { Express } from "express";
    import { UploadDto } from "./user.dto";
    import { FileInterceptor } from "@nestjs/platform-express";

    @Controller("users")
    export class UsersController {
    @Post("upload")
    @UseInterceptors(FileInterceptor("file"))
    upload(
    @Body() upload: UploadDto,
    @UploadedFile()
    file: Express.Multer.File
    ) {
    console.log(upload, file.buffer.toString());
    // { name: "Silence" } 123456
    }
    }
    通过 @UseInterceptors() 装饰器使用拦截器,FileInterceptor() 拦截器第一个参数是 FormData 中文件对应的 key,通过 @UploadedFile() 装饰器将文件信息注入到 file 参数中。
    FormData 中除文件之外的字段以及值会被 @Body() 注入到 upload 参数中。
    Express.Multer.File 对象有以下几个属性:
  • buffer:Buffer 类型,通过 buffer.toString() 可获取到文件的文本内容
  • originalname:文件名
  • mimetype:文件的类型
  • size:文件大小

我们还可以通过管道对文件进行验证,比如验证文件大小,文件类型等等,未通过验证的话直接向客户端返回响应。
Nest 提供了一个内置的管道来处理常见的异常:ParseFilePipe,使用方式如下:

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
import { Body, Controller, Post, UploadedFile, UseInterceptors, ParseFilePipe,
BadRequestException, MaxFileSizeValidator, FileTypeValidator } from "@nestjs/common";
import { Express } from "express";
import { UploadDto } from "./user.dto";
import { FileInterceptor } from "@nestjs/platform-express";

@Controller("users")
export class UsersController {
@Post("upload")
@UseInterceptors(FileInterceptor("file"))
upload(
@Body() upload: UploadDto,
@UploadedFile(new ParseFilePipe({
fileIsRequired: false,
validators: [
new FileTypeValidator({ fileType: "image/png" }),
new MaxFileSizeValidator({ maxSize: 1 * 1024 * 1024 })
],
errorHttpStatusCode: 400,
exceptionFactory: (error) => {
console.log(error);
throw new BadRequestException(error);
}

}))
file: Express.Multer.File
) {
console.log(upload, file.buffer.toString());
}
}

ParseFilePipe 构造函数的参数包含四个字段:

  • fileIsRequired:FormData 中是否必须包含文件,默认为 true,若不包含文件自动响应 400 错误
  • validators:验证器数组,Nest 内置了 FileTypeValidator 和 MaxFileSizeValidator 两个验证器
    • FileTypeValidator:验证文件类型,其原理是根据文件后缀名进行验证
    • MaxFileSizeValidator:验证文件的大小是否小于给定值(单位字节)
  • errorHttpStatusCode:验证失败返回响应的状态码,默认 400
  • exceptionFactory:异常工厂函数,用来自定义返回异常的内容并记录日志,有了这个就不用再定义 errorHttpStatusCode

如果想进行更多的验证,可以自定义验证器,并将其加入到 validators 数组中。
更多关于文件上穿的内容请见 https://docs.nestjs.com/techniques/file-upload

获取请求头

通过 @Headers() 装饰器可以将请求头中的内容注入到参数中:

1
2
3
4
5
6
7
8
9
import { Get, Query, Headers } from "@nestjs/common";

@Controller("users")
export class UsersController {
@Get("name")
getUserName(@Query("id") id: string, @Headers("Content-Type") type: string) {
console.log(type);
}
}

返回响应

返回文字

返回文字内容只需在路由函数中 return 你想返回的内筒即可。由于 Nest 框架会自动将返回结果序列化,因此我们不需要考虑格式问题。

返回文件

从服务端向客户端发送文件,可以使用 StreamableFile 类,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
import { Controller, Get, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';

@Controller('file')
export class FileController {
@Get()
getFile(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'package.json'));
return new StreamableFile(file);
}
}

默认的内容类型(Content-Type)是 application/octet-stream,且没有文件名,如果你需要自定义这个值,你可以使用 StreamableFile 中的 type 选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Controller, Get, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';

@Controller('file')
export class FileController {
@Get()
getFile(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'package.json'));
return new StreamableFile(file, {
type: 'application/json',
disposition: 'attachment; filename="package.json"'
});
}
}
返回状态码

响应状态代码默认为 200,而 POST 请求是 201。我们可以通过 @HttpCode() 装饰器改变响应状态码。

1
2
3
4
5
@Post()
@HttpCode(204)
add() {
return "This action adds a new user";
}
返回响应头

要自定义响应头,可以使用 @Header() 装饰器。

1
2
3
4
5
@Post()
@Header("Cache-Control", "none")
add() {
return "This action adds a new user";
}
动态返回响应

使用装饰器返回状态码和响应头是静态的,但是大多数情况都是需要根据业务逻辑动态决定返回什么,因此,我们需要有其他操作响应的方式。
Nest 框架允许我们通过 @Res() 装饰器将其底层 http 框架(express)的 response 注入到参数中供我们使用。

1
2
3
4
5
6
7
8
9
10
import { Controller, Post, Res, HttpStatus } from "@nestjs/common";
import { Response } from "express";

@Controller("users")
export class UsersController {
@Post()
add(@Res() res: Response) {
res.status(HttpStatus.OK).json([]);
}
}

但是,这种方法会脱离框架提供的响应处理,如 @HttpCode()@Header() 将不再起作用,不能直接通过 return 返回响应等等。此外,代码变得依赖于底层平台(express,fastify)。
要解决这个问题,可以将 passthrough 选项设置为 true,如下所示:

1
2
3
4
5
6
7
8
9
10
11
import { Controller, Post, Res, HttpStatus } from "@nestjs/common";
import { Response } from "express";

@Controller("users")
export class UsersController {
@Post()
add(@Res({ passthrough: true }) res: Response) {
res.status(HttpStatus.OK);
return [];
}
}

Providers 提供者

providers 主要用来提供一些辅助性的功能。许多基本的 Nest 类都可以被视为 providers,如 services、repositories、factories、helpers 等等。
实际上,providers 不一定是类,还可以是对象、字符串、数字等类型。
providers 被设计由 controllers 使用。

注意:@Injectable() 装饰器与 Providers 之间没有必然联系。@Injectable() 意思是可以注入的,指的是可以在被装饰类中注入其他依赖,若不需要注入依赖则不用装饰,而不是只有被装饰的类才可以作为 Provider。

Services 服务

一般来说,controller 只负责接收请求、处理请求参数、返回响应内容,那么具体的业务逻辑就交给 services 去处理。
在项目中添加一个服务,如 users:

1
nest g s users

该命令会在 users 文件夹中创建 users.service.ts 文件。
它看起来像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Injectable } from "@nestjs/common";
import { User } from "./user.interface";

export class UsersService {
getUserList(page: number, limit: number): Array<User> {
return [
{
name: "silence",
age: page + limit,
sex: "male"
}
];
}
}

如需注入其他依赖,应写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Injectable, Inject } from "@nestjs/common";
import { User } from "./user.interface";

@Injectable
export class UsersService {
constructor(@Inject("token") private readonly token: string) {}

getUserList(page: number, limit: number): Array<User> {
return [
{
name: "silence",
age: page + limit,
sex: "male"
}
];
}
}
使用方式

services 使用方式有以下两种。

构造函数注入

在 controller 中这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
import { Controller, Get, Query } from "@nestjs/common"
import { UsersService } from "./users.service";

@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Get("list")
getList(@Query("page") page: number, @Query("limit") limit: number) {
return this.usersService.getUserList(page, limit);
}
}

这里只是将 service 对象作为 controller 的构造函数的参数,就可以直接在路由方法中使用,这是因为 service 的实例化由框架自动完成。
这种将 service 实例注入到构造函数参数中的方式称为构造函数注入。

属性注入

上面的代码也可以替换成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Controller, Get, Query, Inject } from "@nestjs/common"
import { UsersService } from "./users.service";

@Controller("users")
export class UsersController {
@Inject()
private readonly usersService: UsersService;

@Get("list")
getList(@Query("page") page: number, @Query("limit") limit: number) {
return this.usersService.getUserList(page, limit);
}
}

@Inject() 装饰器会将 service 的实例注入到声明为 service 对象的 controller 的属性中,这种方式称为属性注入。
为什么需要属性注入呢?它与构造函数注入有什么不同?
想象一下,你定义了一个 service,它依赖了其他的 provider,像下面这样:

1
2
3
4
5
6
7
8
@Injectable()
export class MyService {
constructor(
protected readonly firstProvider: FirstProvider,
protected readonly secondProvider: SecondProvider,
protected readonly thirdProvider: ThirdProvider
) {}
}

现在,你想要实现一个子类继承 MyService,那么,你需要在子类的 super() 方法中传递所有参数:

1
2
3
4
5
6
7
8
9
10
@Injectable()
export class MySubService extends MyService {
constructor (
private readonly firstProvider: FirstProvider,
private readonly secondProvider: SecondProvider,
private readonly thirdProvider: ThirdProvider
) {
super(firstProvider, secondProvider, thirdProvider);
}
}

这样的话,MyService 的子类,子类的子类…都需要传递大量参数,非常不便。为避免这种情况,可以使用属性注入的方法,子类不需要 super() 父类中的 Provider 实例。

注意:如果一个 Service 没有被继承,那么建议使用构造函数注入。构造函数显式地概述了需要哪些依赖项,并提供比属性注入更好的可见性。

自定义 Provier

在 Modules 中,我们这样注册 providers:

1
2
3
4
5
6
7
import { UsersService } from "./users.service";

@Module({
providers: [UsersService]
})
export class UsersModule {
}

它其实是下面这种写法的缩写:

1
2
3
4
5
6
7
8
9
10
11
12
import { UsersService } from "./users.service";

@Module({
providers: [
{
provide: UsersService,
useClass: UsersService
}
]
})
export class UsersModule {
}

其中,provide 字段是 provider 的 token,用于请求同名类的实例,useClass 指定 provider 提供的类。

Class providers:useClass

对于 Class 类型的 providers,一般使用简写即可,但有些场景需要我们动态选择 providers 的类,就需要用到 useClass
useClass 语法允许我们动态确定令牌应解析到的类。例如,我们有一个抽象类 ConfigService,我们希望 Nest 可以根据当前环境提供不同的配置服务。下面的代码实现了这样的策略。

1
2
3
4
5
6
7
8
9
10
11
12
@Module({
providers: [
{
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
}
],
})
export class AppModule {}

由于 DevelopmentConfigService 类和 ProductionConfigService 类都继承自 ConfigService,因此,在 provider 注入时类型写 ConfigService 即可。

1
constructor(private readonly configService: ConfigService);

若想要自定义令牌,如:

1
2
3
4
5
6
7
8
9
10
11
12
import { UsersService } from "./users.service";

@Module({
providers: [
{
provide: "users-service",
useClass: UsersService
}
],
})
export class AppModule {
}

那么在 controller 文件中就要这样注入依赖:

1
2
3
4
@Controller("users")
export class UsersController {
constructor(@Inject("users-service") private readonly usersService: UsersService) {}
}
Non-class-based providers:useValue

虽然 providers 经常提供服务,但他们并不限于这种用途,providers 可以提供任何值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Module({
providers: [
{
provide: "person",
useValue: {
name: "Silence",
age: 20
}
},
{
provide: "log",
useValue: () => {
console.log("log provider");
}
},
{
provide: "name",
useValue: "Silence"
}
],
})
export class UsersModule {}

在 controller 文件中注入:

1
2
3
4
5
6
7
8
@Controller("users")
export class UsersController {
constructor(
@Inject("person") private readonly person: { name: string; age: number },
@Inject("log") private readonly log: () => void,
@Inject("name") private readonly name: string
) {}
}
Factory providers:useFactory

useFactory 允许动态创建 providers,它的返回值就是 provider 要提供的值。支持异步。
inject 字段是一个 providers 数组,数组中的 providers 会按顺序被注入到 userFactory 的参数中供其使用。

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
@Module({
providers: [
{
provide: "person",
useValue: {
name: "Silence"
}
},
{
provide: "log",
useValue: (msg: string) => {
console.log(msg);
}
},
{
provide: "log-person",
useFactory: (log: (msg: string) => void, person?: { name: string; }) => {
return () => {
log(person?.name);
};
},
inject: ["log", { token: "person", optional: true }]
}
],
})
export class UsersModule {
}
导出自定义 provider

可以直接导出 token,也可以导出整个 provider。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const logProvider = {
provide: "log",
useValue: () => {
console.log("log provider");
}
}
@Module({
providers: [
{
provide: "person",
useValue: {
name: "Silence",
age: 20
}
},
logProvider
],
exports: ["person", logProvider]
})
export class UsersModule {}

Middleware 中间件

中间件是在路由处理器之前调用的函数。
中间件函数可以访问 request 和 response 对象,以及 next() 方法(下一个中间件函数)。
中间件有以下几个功能:

  1. 对 request 和 response 对象进行更改
  2. 结束请求-响应周期(直接返回响应)
  3. 调用下一个中间件函数
  4. 如果当前的中间件函数没有结束请求-响应周期, 它必须调用 next() 将控制传递给下一个中间件函数。否则,请求将被挂起。

我们可以在函数或带有 @Injectable() 装饰器的类中自定义中间件。

类中间件

使用类的话,需要实现 NestMiddleware 接口。
生成一个中间件文件:

1
nest g mi auth middlewares --flat
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";

@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
if (req.headers.Authorization) {
next();
} else {
res.status(403).send("没有权限");
}
}
}

函数中间件

如果我们想定义一个中间件,它没有成员,没有额外的方法,也没有依赖项,那么完全可以使用一个简单的函数而不是类。

1
2
3
4
5
6
7
import { Request, Response, NextFunction } from "express";

export function logger(req: Request, res: Response, next: NextFunction) {
console.log("请求路径:", req.baseUrl);
console.log("请求方法:", req.method);
next();
}

应用中间件

我们需要在 Module 层面应用中间件,Module 类须实现 NestModule 接口的 configure() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { logger } from "../middlewares/logger.middleware";
import { AuthMiddleware } from "../middlewares/auth.middleware";

@Module({
imports: [],
controllers: [UsersController],
providers: []
})
export class UsersModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(logger, AuthMiddleware)
.forRoutes("*");
}
}

apply() 方法用于注册中间件,其参数的顺序决定了中间件的调用顺序。
forRoutes() 方法用于限制中间件只能作用于符合规定路由的请求。其参数可以是字符出串(路由通配符),RouteInfo 对象,和 Controller。

注意:

  1. configure() 方法支持 async/await。
  2. 中间件的作用范围和它是在哪个模块中被注册的无关,而是取决于路由规则。

MiddlewareConsumer 支持链式调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { RequestMethod } from "@nestjs/common";

@Module({
imports: [],
controllers: [UsersController],
providers: []
})
export class UsersModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(logger)
.forRoutes(UsersController)
.apply(AuthMiddleware)
.forRoutes({ path: "*", method: RequestMethod.ALL });
}
}

排除路由

有时候我们想排除某些路由,不应用中间件。可以用 exclusive() 方法。此方法可以采用单个字符串、多个字符串或 RouteInfo 对象来标识要排除的路由,如下所示:

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

设置全局中间件

可以使用 app.use() 方法来注册全局中间件:

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

注意:此方法只能注册函数中间件和没有依赖注入的类中间件,对于有依赖注入的类中间件,需要在根模块(或任何其他模块)中用 .forRoutes('*')

Pipes 管道

管道有两个典型的应用场景:

  1. 转换:将输入数据转换为期望的格式,如将字符串转换为数字
  2. 验证:验证输入的数据,如果合格则通过,否则抛出异常

管道对控制器路由的参数进行操作,Nest 在调用路由方法之前插入一个管道,管道接收指定给方法的参数并对它们进行操作。转换或验证操作都在此时发生,之后路由处理程序使用的是转换后的参数。
Nest 配备了许多内置管道,可以使用开箱即用。

内置管道

  • ValidationPipe:验证参数类型,并将其转换为声明的类型
  • ParseIntPipe:验证参数类型,并将其转换为 Int 类型
  • ParseFloatPipe:验证参数类型,并将其转换为 Float 类型
  • ParseBoolPipe:验证参数类型,并将其转换为 Boolean 类型
  • ParseArrayPipe:按照规则验证参数并将其转换为数组
  • ParseUUIDPipe:验证参数否为 uuid
  • DefaultValuePipe:为参数添加默认值
  • ParseFilePipe:解析文件
    ValidationPipe
    ValidationPipe 提供了一种方便的方法来为所有传入的有效载荷实施验证规则,其中特定的规则是通过装饰器装饰 DTO 类的属性来声明的。
    在使用之前,需安装依赖:
    1
    npm i class-validator class-transformer
    下面介绍一些该管道常用的配置项:
选项 类型 描述
transform boolean 为 true 则将参数转换为声明的类型
disableErrorMessages boolean 为 true 则不会将验证的错误返回给客户端
whitelist boolean 为 true 则将从返回结果中剥离 DTO 中未用类验证装饰器装饰的属性
errorHttpStatusCode number 指定返回错误的状态码,默认 400
exceptionFactory Fuction 获取错误数组并自定义抛出异常
自动验证

假如现在定义了这样一个路由处理器:

1
2
3
4
@Post()
addUser(@Body() user: UserDto) {
console.log(user)
}

我们希望能够验证 UserDto 属性的类型,此时就可以将 ValidationPipeclass-validator 结合起来。
先为 UserDto 的属性添加验证规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { IsString, IsNumber, IsEnum } from "class-validator";

export class UserDto {
@IsString()
name: string;

@IsNumber()
age: number;

@IsEnum(["male", "female"])
sex: "male" | "female";

@IsPhoneNumber()
phoneNumber: string;
}

然后在路由处理器中使用 ValidationPipe

1
2
3
4
5
@Post()
@UsePipes(ValidationPipe)
addUser(@Body() user: UserDto) {
console.log(user)
}

此时,请求中的载荷会被自动验证,验证失败则会向客户端返回 400 错误,错误信息是一个数组。
我们可以通过 disableErrorMessages: true 禁用详细错误信息,或使用 exceptionFactory 自定义错误信息。

剥离属性

ValidationPipe 还可以过滤掉不应该被方法处理器接收的属性。通过 whitelist: true 来启用这一特性,其原理是被验证装饰器装饰的属性会被加入白名单并保留,而那些没有被装饰的属性就会被过滤掉。
例如 DTO 文件是下面这样:

1
2
3
4
5
6
7
8
import { IsString, IsNumber, IsEnum } from "class-validator";

export class UserDto {
@IsString()
name: string;

age: number;
}

那么路由处理器接收到的参数不包含 age 属性。

转换载荷

通过网络进入的有效负载是普通的 JavaScript 对象。ValidationPipe 可以自动将有效负载转换为其 DTO 类的对象。
要启用自动转换,请设置 transform: true
启用后,它不仅可以将载荷转换为 DTO 对象,还可以将参数转换为其声明的类型,相当于 ParseIntPipeParseBoolPipe

ValidationPipe 一般在全局使用。

ParseArrayPipe

TypeScript 不存储有关泛型或接口的元数据,因此,在 DTO 中使用它们时,ValidationPipe 可能无法正确验证传入的数据。
例如,在下面的代码中,将无法正确验证 users:

1
2
3
4
5
@Post()
@UsePipes(ValidationPipe)
addUser(@Body() users: Array<UserDto>) {
console.log(users);
}

而且,users 也不会被转换为 UserDto 对象数组。
这时,就需要 ParseArrayPipe 去验证数组:

1
2
3
4
5
@Post()
@UsePipes(new ParseArrayPipe({ items: UserDto }))
addUser(@Body() users: Array<UserDto>) {
console.log(users);
}

这时,数组的验证和转换都正常了。
此外,它还可以根据分隔符将参数转换为数组,例如参数是一个 id 字符串:”1,2,3”:

1
2
3
4
5
6
7
@Get()
findByIds(
@Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
ids: number[]
) {
console.log(ids); // [1, 2, 3]
}

自定义管道

创建一个 pipe 文件:

1
nest g pi custom pipes --flat

我们需要实现 PipeTransformtransform 方法。

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

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

transform 方法有两个参数:

  • value:当前路由处理方法的参数(在路由处理方法接收到它之前)
  • metadata:当前路由处理方法参数的元数据,有以下属性:
    1
    2
    3
    4
    5
    6
    7
    8
    export interface ArgumentMetadata {
    // 表示提取参数的装饰器类型
    type: 'body' | 'query' | 'param' | 'custom';
    // 参数的元类型,例如 String,UserDto,或 undefined
    metatype?: Type<unknown>;
    // 传递给参数装饰器的字符串,例如 @Body('[data]')。如果未传参,它是 undefined。
    data?: string;
    }
    接下来我们实现一个自定义的 ParseIntPipe:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from "@nestjs/common";

    @Injectable()
    export class CustomPipe implements PipeTransform {
    transform(value: any, metadata: ArgumentMetadata) {
    const { metatype, type } = metadata;
    if ((type === "query" || type === "param") && metatype.name === "Number") {
    const res = new metatype(value);
    if (res.toString() === "NaN") throw new BadRequestException();
    return res;
    }
    return value;
    }
    }

    管道的使用位置

    管道的使用位置有四种。
    1. 参数管道
    管道可以作为 @Query()@Body()@Param() 等装饰器的参数,只作用于当前路由的特定参数。
    1
    2
    3
    4
    @Get()
    getList(@Query("page", ParseIntPipe) page: number, @Query("limit") limit: number) {
    console.log(page, limit)
    }
    2. 路由处理器管道
    通过 @UsePipes() 装饰路由处理器,并将管道传入 @UsePipes() 中,此时管道作用于当前路由处理器的所有参数。
    1
    2
    3
    4
    5
    @Get()
    @UsePipes(ParseIntPipe)
    getList(@Query("page") page: number, @Query("limit") limit: number) {
    console.log(page, limit)
    }
    3. 控制器管道
    与路由处理器管道类似,只不过 @UsePipes() 装饰的是控制器,此时管道作用于当前控制器的所有路由处理器的参数。
    1
    2
    3
    @Controller("users")
    @UsePipes(ParseIntPipe)
    export class UsersController {}
    4. 全局管道
    1
    2
    3
    4
    5
    const app = await NestFactory.create(AppModule);

    app.useGlobalPipes(new ValidationPipe({ transform: true, disableErrorMessages: true }));

    await app.listen(3000);
    这种方式无法进行依赖注入。解决方法:可以作为 provider 在任意模块中设置以供全局使用:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Module({
    providers: [
    {
    provide: "validation-pipe",
    useClass: ValidationPipe
    }
    ]
    })
    export class AppModule {}
    全局管道作用于所有请求的参数。

    ExecutionContext 执行上下文

    ArgumentsHost

    ArgumentsHost 类提供用于检索传递给处理程序的参数的方法。
    它允许选择适当的上下文(HTTP、RPC、微服务或 WebSockets)来检索参数。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * Switch context to RPC.
    */
    switchToRpc(): RpcArgumentsHost;
    /**
    * Switch context to HTTP.
    */
    switchToHttp(): HttpArgumentsHost;
    /**
    * Switch context to WebSockets.
    */
    switchToWs(): WsArgumentsHost;
    Http 上下文
    switchToHttp() 方法返回一个适用于 Http 上下文的 HttpArgumentsHost 对象,他有两个有用的方法:
    1
    2
    3
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    分别可以获取到 request 对象和 response 对象。
    Websocket 上下文
    switchToWs() 方法返回一个适用于 Websocket 上下文的 WsArgumentsHost 对象,他有两个有用的方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    export interface WsArgumentsHost {
    /**
    * Returns the data object.
    */
    getData<T>(): T;
    /**
    * Returns the client object.
    */
    getClient<T>(): T;
    }
    分别可以获取到传输数据和客户端连接。

    ExecutionContext

    ExecutionContext 继承了 ArgumentsHost,提供了关于当前执行进程的其他信息。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    export interface ExecutionContext extends ArgumentsHost {
    /**
    * Returns the type of the controller class which the current handler belongs to.
    */
    getClass<T>(): Type<T>;
    /**
    * Returns a reference to the handler (method) that will be invoked next in the
    * request pipeline.
    */
    getHandler(): Function;
    }
    getHandler() 方法返回一个将要被调用的路由处理程序的引用。
    getClass() 方法返回该路由处理程序所属的控制器的类。
    执行上下文有两个重要作用:
  1. 提供不同上下文的参数检索对象(HttpArgumentsHost,WsArgumentsHost)
  2. 让我们可以在上下文对象中获取在路由处理程序中添加的元数据

作用一已在上文中说明,下面介绍作用二。

添加元数据

Nest 提供了将自定义元数据附加到路由处理程序的能力,可以通过 Reflector 创建的装饰器,以及内置的 @SetMetadata() 装饰器来实现。
创建一个装饰器文件:

1
nest g d roles decorators --flat
Reflector

Reflector 创建强类型装饰器,我们需要指定类型参数。例如,让我们创建一个 Roles 装饰器,它接受一个字符串数组作为参数。

1
2
3
import { Reflector } from '@nestjs/core';

export const Roles = Reflector.createDecorator<string[]>();

使用:

1
2
3
4
5
@Post()
@Roles(['admin'])
create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}

此时,admin 就被附加到了 create 这个路由处理程序上。

SetMetadata

创建一个装饰器:

1
2
3
import { SetMetadata } from '@nestjs/common';

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

与 Reflector 的不同之处在于,我们可以对元数据键和值进行更多控制,并且还可以创建接受多个参数的装饰器。
使用:

1
2
3
4
5
@Post()
@Roles('admin', 'user')
create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}

获取元数据

Reflector
1
const roles = this.reflector.get(Roles, context.getHandler());

context 为执行上下文对象。
如果 @Roles() 被添加到了控制器上,那么这样获取元数据:

1
const roles = this.reflector.get(Roles, context.getClass());

this.reflector 是 Reflector 对象。
如果我们在两个级别上都提供了 Roles 元数据:

1
2
3
4
5
6
7
8
9
@Roles(['user'])
@Controller('cats')
export class CatsController {
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
}

如果将 "user" 视为默认角色,只想知道该路由处理器是否需要 "admin"" 角色,使用以下方法:

1
2
const roles = this.reflector.getAllAndOverride(Roles, [context.getHandler(), context.getClass()]);
// ["admin"]

如果想要获取所有的角色,使用以下方法:

1
2
const roles = this.reflector.getAllAndMerge(Roles, [context.getHandler(), context.getClass()]);
// ["user", "admin"]
SetMetadata

通过 SetMetadata 添加的元数据也使用 reflector 获取:

1
const roles = this.reflector.get<string[]>('roles', context.getHandler());

不同的是它传入 get 方法的第一个参数是创建时使用的 key。

Guards 守卫

guards 只有一个责任,它们根据特定的条件(如权限、角色、权限等)来决定路由处理程序是否处理给定的请求,这通常被称为授权
中间件也是一个很好的身份验证选择,但它不知道在调用 next() 函数后将执行哪个处理程序。而 guards 可以访问 ExecutionContext 执行上下文,因此它知道接下来要执行什么。

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

定义

创建一个 guards 文件:

1
nest g gu auth guards --flat

guards 需要实现 CanActivate 接口的 canActivate 方法:

1
2
3
4
5
6
7
8
9
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Observable } from "rxjs";

@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}

canActivate 方法的参数是执行上下文,它应该返回一个布尔值,表示当前请求是否被允许。它也可以通过 通过 PromiseObservable 异步地返回一个布尔值。

  • 返回 true,请求将被处理
  • 返回 false,请求将被拒绝,Nest 会自动返回 403。如果想返回一个不同的错误响应,抛出自定义异常

    应用

    假如现在我们使用了 JWT 去做身份验证,其颁发的 token 中包含了用户的角色:admin 和 user,我们需要在 guards 中验证 token 的正确性和时效性,以及角色是否有权访问当前路由。
    要知道当前路由绑定了什么角色,需要用到上节学到的知识:执行上下文。
    比如现在有这样两个路由:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Post('create')
    @Roles(['admin'])
    create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
    }

    @Get('list')
    @Roles(['user', 'admin'])
    get() {
    this.catsService.get();
    }
    /create 路由只允许 admin 访问,/list 路由允许 admin 和 user 访问。
    接下来定义 guards:
    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
    import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
    import { Observable } from "rxjs";
    import { Request } from "express";
    import { Reflector } from "@nestjs/core";
    import { UnauthorizedException } from "@nestjs/common";
    import { JwtService } from "@nestjs/jwt";
    import { Roles } from "../decorator/roles.decorator";

    @Injectable()
    export class AuthGuard implements CanActivate {
    constructor(
    private reflector: Reflector,
    private jwtService: JwtService
    ) {}

    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const token = request.headers.authorization;
    if (!token) {
    throw new UnauthorizedException();
    }
    let role: string;
    try {
    role = this.jwtService.verify(token).role;
    } catch {
    throw new UnauthorizedException();
    }
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles || roles.includes(role)) return true;
    throw new UnauthorizedException();
    }
    }

    使用

    guards 可以在方法级别(路由处理器)、类级别(控制器)和全局级别使用。
    方法级别
    1
    2
    3
    4
    5
    @Get()
    @UseGuards(AuthGuard)
    get() {
    return [];
    }
    类级别
    1
    2
    3
    @Controller('users')
    @UseGuards(AuthGuard)
    export class UsersController {}
    全局
    1
    2
    const app = await NestFactory.create(AppModule);
    app.useGlobalGuards(new AuthGuard());
    但这种方式无法进行依赖注入,因此,可以在任意模块中将其作为 provider 供全局使用。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Module({
    providers: [
    {
    provide: "auth-guard",
    useClass: AuthGuard
    },
    ],
    })
    export class AppModule {}

    Interceptors 拦截器

    拦截器有以下几个功能:
  1. 在函数执行之前/之后添加额外逻辑
  2. 转换从函数返回的结果
  3. 转换函数引发的异常
  4. 扩展基本功能
  5. 根据特定条件完全覆盖函数(例如,用于缓存)

这里的”函数“指的是路由处理程序。

定义

创建一个拦截器文件:

1
nest g itc custom interceptors --flat

拦截器需要实现 NestInterceptorintercept 方法:

1
2
3
4
5
6
7
8
9
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";

@Injectable()
export class CustomInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle();
}
}

intercept 方法的第一个参数是执行上下文,第二个参数是一个回调函数,CallHandler.handle() 是当前的路由处理程序,如果它没被调用,路由程序方法就不会执行。
在调用 CallHandler.handle() 之前的代码逻辑实现了前向拦截,那如何实现后向拦截呢?由于 CallHandler.handle() 返回一个 Observable,因此我们可以使用 RxJS 操作符来操作响应。

tap 操作符:打印日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";

@Injectable()
export class CustomInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log("before");
return next.handle().pipe(
tap(() => {
console.log("after");
})
);
}
}
map 操作符:修改响应数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { tap, map } from "rxjs/operators";

@Injectable()
export class CustomInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log("before");
return next.handle().pipe(
tap(() => {
console.log("after");
}),
map((data) => ({ ...data, statusCode: 400 }))
);
}
}
catchError 操作符:覆盖异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { CallHandler, ExecutionContext, ForbiddenException, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, throwError } from "rxjs";
import { tap, map, catchError } from "rxjs/operators";

@Injectable()
export class CustomInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log("before");
return next.handle().pipe(
tap(() => {
console.log("after");
}),
map((data) => ({ ...data, statusCode: 400 })),
catchError(() => throwError(() => new ForbiddenException()))
);
}
}
创建操作符:覆盖流

有时候我们想在不调用事件处理程序的情况下返回响应,那么就不能调用 CallHandler.handle() 方法,但是需要返回一个 Observable 对象,这时就可以使用创建操作符去创建一个新的 Observable 对象并返回。
常见的创建操作符有 offromempty 等等。
假如我们设置了缓存,如果缓存时间没到,则不触发路由处理程序,直接返回缓存中的内容,否则通过路由处理程序返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, throwError, of } from "rxjs";
import { Reflector } from "@nestjs/core";
import { CacheService } from "./cache.service";

@Injectable()
export class CustomInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
private cacheService: CacheService
) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const caches = this.reflector.get(Caches, context.getHandler());
const { isExpired, cacheData } = this.cacheService.get(caches[0]);
if (isCached) return of({
statusCode: 200,
data: cacheData
});
return next.handle();
}
}

使用

与 Guards 一样,拦截器也有三个作用域:路由处理程序、控制器、全局。

路由处理程序
1
2
3
4
5
@Get()
@UseInterceptors(CustomInterceptor)
get() {
return [];
}
控制器
1
2
@UseInterceptors(CustomInterceptor)
export class UsersController {}
全局

在任意模块中作为 provider 供全局使用。

1
2
3
4
5
6
7
8
9
@Module({
providers: [
{
provide: "custom-interceptor",
useClass: CustomInterceptor
}
]
})
export class AppModule {}