NestJS-系列
NestJS[一]-基础
RxJS
NestJS[二]-核心
NestJS[三]-进阶
NestJS[四]-数据库

Logger日志

node服务端常用的日志收集工具:winstonlog4jsbunyannpmlog

Nest内置了一个基于文本的日志记录器:Logger

类结构:

  1. LoggerService 是接口标准,如果要替换掉内置的日志类,最低要求是得符合这个接口。
  2. ConsoleLogger 日志主要业务类,主要负责处理格式化日志字符串输出。
  3. Logger 更高层封装,加入了输出缓存,并统一管理日志等级。自身有一个ConsoleLogger的默认单例。

配置Logger

在创建Nest应用时,通过NestApplicationOptions.logger来配置Logger,用于系统日志记录。

注意:这些配置不会影响到自定义Logger,只作用于内置Logger类

1
logger?: LoggerService | LogLevel[] | false;

设置为false时,将禁用Logger日志

1
2
3
const app = await NestFactory.create(AppModule, {
logger: false,
});

还可以指定输出的最低日志等级,优先级更高的也将被输出。

1
2
3
const app = await NestFactory.create(AppModule, {
logger: ['log'], // log及以上等级的日志将被输出,如warn、error
});

Logger默认提供了6个日志等级

1
2
3
4
5
6
7
8
9
10
type LogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose' | 'fatal';
const LOG_LEVEL_VALUES = {
// 优先级从低到高
verbose: 0,
debug: 1,
log: 2,
warn: 3,
error: 4,
fatal: 5,
};

自定义Logger

实现了LoggerService接口的自定义Logger,可以被Nest应用使用,相当于完全重写并覆盖内置Logger

1
2
3
4
5
6
7
8
9
interface LoggerService {
log(message: any, ...optionalParams: any[]): any;
error(message: any, ...optionalParams: any[]): any;
warn(message: any, ...optionalParams: any[]): any;
debug?(message: any, ...optionalParams: any[]): any;
verbose?(message: any, ...optionalParams: any[]): any;
fatal?(message: any, ...optionalParams: any[]): any;
setLogLevels?(levels: LogLevel[]): any;
}

若只需要扩展内置的Logger,可以继承ConsoleLogger类,覆盖需要扩展的方法

1
2
3
4
5
6
// class ConsoleLogger implements LoggerService {}
class MyLogger extends ConsoleLogger {
error(message: any, stack?: string, context?: string) {
super.error(...arguments);
}
}

在Nest应用中使用自定义Logger,用于系统日志记录

1
2
3
4
const app = await NestFactory.create(AppModule, {
logger: new MyLogger(), // 使用自定义Logger
// logger: console // 使用console对象
});

可以通过 set*() 等实例方法配置自定义Logger,如 setLogLevels() 设置输出的最低日志等级。

1
this.logger.setLogLevels(['error']);

日志格式

Logger默认的日志格式如下:

1
[AppName] [PID] [Timestamp] [LogLevel] [Context] Message [+ms]
  1. AppName 应用程序名,被固定为[Nest]
  2. PID:系统分配的进程编号
  3. Timestamp:当前日志输出的格式化系统时间
  4. LogLevel:日志等级文本
  5. Context:上下文
  6. Message:输出的消息,可以是对象类型输出
  7. +ms:两次输出日志的时间间隔,时间戳

使用Logger

通过Logger类手动实例化一个Logger对象,用于输出日志,还可以使用app.useLogger()

1
2
3
4
5
class Logger implements LoggerService {
constructor(context: string, options?: {
timestamp?: boolean;
});
}

其构造函数接受两个参数:

  1. context:上下文,用于标识日志输出的位置
  2. options:配置项,只有一个timestamp属性,表示是否输出 +ms,默认为false

其日志方法接收两个参数:

  1. message:输出的消息,可以是对象类型,配合format()方法,用于格式化输出多个消息
  2. context:上下文,用于标识日志输出的位置。

注意:当Logger实例也配置了context时,以Logger实例的context为准,并且会在输出的日志中追加一条日志方法的context信息。

1
2
3
4
5
6
7
import { Logger } from '@nestjs/common';
const logger = new Logger('app', {
timestamp: true,
});
logger.log(format('%s %s %s', 'GET', '/test', 'test'), 'log');
// [Nest] 28128 - 2024/02/17 14:08:45 LOG [app] GET /test test +1107ms
// [Nest] 28128 - 2024/02/17 14:08:45 LOG [app] log +1ms // 追加的日志方法的context信息

依赖注入Logger

手动实例化Logger对象,破坏了单例,不利于统一日志。

包装一层 LoggerModule,通过依赖注入的方式,统一管理Logger实例。

1
2
3
4
5
6
7
8
import { Module } from '@nestjs/common';
import { MyLoggerService } from './my-logger.service';

@Module({
providers: [MyLoggerService],
exports: [MyLoggerService],
})
export class MyLoggerModule {}
1
2
3
4
import { ConsoleLogger, Injectable } from '@nestjs/common';

@Injectable()
export class MyLoggerService extends ConsoleLogger {}

设置为全局模块,或在需要的地方导入使用,使用 setContext() 设置上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { MyLoggerService } from './my-logger/my-logger.service';
@Controller()
export class AppController {
constructor(
private readonly logger: MyLoggerService,
) {
this.logger.setContext(AppController.name);
}
@Get('test')
getTest() {
this.logger.log(format('%s %s %s', 'GET', '/test', 'test'));
}
}

若需用于系统日志记录,还需要通过 app.useLogger() 应用自定义Logger,并开启 bufferLogs 日志缓冲。

1
2
3
4
5
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
// app.get()调用检索MyLoggerService的单例实例,并依赖于首先在另一个模块中注入该实例
app.useLogger(app.get(MyLoggerService));

app.get() 可以检索某个类型的单例实例,依赖于首先在另一个模块中注入该实例。无需再重复实例化。

ModuleRef模块引用

ModuleRef 类用于检索模块中的提供者实例,并使用注入令牌作为查找键名来获取实例的引用。

ModuleRef 可以通过常规方法注入到类中。

1
2
3
4
import { ModuleRef } from '@nestjs/core';
class AppController {
constructor(private readonly moduleRef: ModuleRef) {}
}

获取实例

ModuleRef.get() 使用注入标记/类名检索当前模块中存在(已实例化)的提供者、控制器或可注入项(例如,守卫、拦截器等)

若该提供者是其它模块定义并暴露的,需要将strict设置为false,从全局上下文中检索。

1
2
import { UserService } from './user/user.service';
moduleRef.get(UserService, { strict: false });

ModuleRef 无视了模块的依赖关系,能直接从全局上下文中检索提供者实例。
若需要另一个模块的提供者实例,可以不暴露该提供者、不导入该模块,直接通过ModuleRef检索。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller('download')
export class DownloadController {
private userService: UserService;
constructor(
private readonly moduleRef: ModuleRef,
) {
this.userService = moduleRef.get(UserService, { strict: false });
}
@Get()
get() {
return this.userService.findOne(1);
}
}

注意:不能通过get()方法检索作用域提供者(瞬态或请求作用域)

解析作用域提供者

通过 ModuleRef.resolve() 方法解析作用域提供者,返回该提供者的唯一实例(Promise)。

多次调用该方法,返回的是不同的实例。

1
2
3
4
5
const transientServices = await Promise.all([
this.moduleRef.resolve(TransientService),
this.moduleRef.resolve(TransientService),
]);
console.log(transientServices[0] === transientServices[1]); // false

为了在多个 resolve() 调用之间生成单个实例,可以通过 ContextIdFactory 创建一个上下文标识,并传递给 resolve() 方法。相同的上下文标识,返回的是相同的实例。

1
2
3
4
5
6
7
const contextId = ContextIdFactory.create();
const transientServices = await Promise.all([
// 传递相同的上下文标识
this.moduleRef.resolve(TransientService, contextId),
this.moduleRef.resolve(TransientService, contextId),
]);
console.log(transientServices[0] === transientServices[1]); // true

请求提供者

若通过 resolve() 解析请求作用域提供者,会导致 REQUEST 提供者注入为 undefined。因为它们不是由 Nest 依赖注入系统实例化和管理的。

一个请求作用域提供者
1
2
3
4
5
6
7
8
9
10
11
import { REQUEST } from '@nestjs/core';
@Injectable()
export class AppService {
@Inject(REQUEST)
res: Request;

getHello(): string {
console.log(this.res.url); // /value
return 'Hello World!';
}
}

需要使用 registerRequestByContextId() 方法,先将请求对象注册到上下文中,再通过 resolve() 方法解析请求作用域提供者。

1
2
3
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId(/* YOUR_REQUEST_OBJECT */, contextId);
await this.moduleRef.resolve(TransientService, contextId),

若在请求提供者中解析另一个请求提供者,可以更方便地通过 getByRequest() 方法基于请求对象创建一个上下文标识,并将其传递给 resolve() 调用:

1
2
const contextId = ContextIdFactory.getByRequest(this.request);
await this.moduleRef.resolve(TransientService, contextId);

动态实例化

ModuleRef.create() 动态实例化一个之前没有注册为提供者的类。

即使该类已经被注册为提供者且已经实例化,也会创建一个新的实例(Promise)。

1
await this.moduleRef.create(oneService);

与直接 new 不同,create() 会处理依赖注入,但这些依赖必须是执行 create() 所在的模块中能访问到的,缺失的依赖需要导入到该模块中。

生命周期事件

Nest 应用以及每个应用元素都有一个由 Nest 管理的生命周期。文档

Nest 提供了许多钩子方法,用于在生命周期的不同阶段执行自定义逻辑。

  1. onModuleInit() 一旦解决了模块的依赖,就会调用。
  2. onApplicationBootstrap() 在所有模块初始化后调用,但在监听连接之前。
  3. onModuleDestroy()* 在收到终止信号(如 SIGINT)或 app.close() 后调用。
  4. beforeApplicationShutdown()* 在所有 onModuleDestroy 完成后调用。
  5. onApplicationShutdown()* 在连接关闭后调用。

注意:请求作用域的类没有生命周期钩子。它们专门为每个请求创建,并在发送响应后自动进行垃圾回收。

*若没有显式调用 app.close(),则三个关闭钩子默认是不会监听系统信号(如 SIGINT)的,监听系统信号会消耗较多的系统资源。可以通过 app.enableShutdownHooks() 启用对系统信号的监听,但信号在windows上可能无法预期地工作。

使用钩子

每个生命周期钩子都由一个接口表示。

onModuleInit() 为例:需要实现 OnModuleInit 接口。

1
2
3
4
5
6
@Injectable()
export class UserService implements OnModuleInit {
onModuleInit() {
console.log(`The UserService has been initialized.`);
}
}

如果生命周期钩子返回一个 Promise,Nest 将等待这个 Promise 完成(或者解决)之后再继续生命周期。

所以onModuleInit()onApplicationBootstrap()钩子可以是异步的,以推迟模块的初始化,可以完成如数据库连接等异步工作后,在完成应用初始化、监听连接。

1
2
3
4
5
6
7
8
9
10
class UserService implements OnModuleInit {
async onModuleInit(): Promise<void> {
return await new Promise((resolve) => {
setTimeout(() => {
console.log('初始化完成');
resolve();
}, 3000);
});
}
}

整体案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export class AppModule {
onModuleInit(): any {
console.log('onModuleInit.');
}
onApplicationBootstrap(): any {
console.log('onApplicationBootstrap down.');
}
onModuleDestroy(): any {
console.log('onModuleDestroy');
}
beforeApplicationShutdown(signal?: string): any {
console.log('beforeApplicationShutdown:', signal);
}
onApplicationShutdown(signal?: string): any {
console.log('OnApplicationShutdown:', signal);
}
}
1
2
3
4
5
6
onModuleInit.
onApplicationBootstrap down.
[Nest] 20808 - 2024/02/17 18:03:51 LOG [NestApplication] Nest application successfully started
onModuleDestroy
beforeApplicationShutdown: SIGINT
OnApplicationShutdown: SIGINT

swagger接口文档

Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务的接口文档。

Swagger UI 用于将 Swagger 规范生成的文档呈现为交互式的、动态的 API 文档。

在Nset中使用需安装@nestjs/swaggernpm i -S @nestjs/swagger文档

使用 SwaggerModule 类初始化 Swagger 文档,DocumentBuilder 类配置文档。

main.ts 基本使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 配置Swagger
const config = new DocumentBuilder()
.setTitle('接口文档')
.setDescription('一个API文档')
.setVersion('1.0')
.build();
// 生成文档
const document = SwaggerModule.createDocument(app, config);
// 启动文档
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}

DocumentBuilder 提供了一系列的方法用于构建符合 OpenAPI 规范的基本文档。

DocumentBuilder类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class DocumentBuilder {
private readonly logger;
private readonly document;
setTitle(title: string): this;
setDescription(description: string): this;
setVersion(version: string): this;
setTermsOfService(termsOfService: string): this;
setContact(name: string, url: string, email: string): this;
setLicense(name: string, url: string): this;
addServer(url: string, description?: string, variables?: Record<string, ServerVariableObject>): this;
setExternalDoc(description: string, url: string): this;
setBasePath(path: string): this;
addTag(name: string, description?: string, externalDocs?: ExternalDocumentationObject): this;
addExtension(extensionKey: string, extensionProperties: any): this;
addSecurity(name: string, options: SecuritySchemeObject): this;
addGlobalParameters(...parameters: ParameterObject[]): this;
addSecurityRequirements(name: string | SecurityRequirementObject, requirements?: string[]): this;
addBearerAuth(options?: SecuritySchemeObject, name?: string): this;
addOAuth2(options?: SecuritySchemeObject, name?: string): this;
addApiKey(options?: SecuritySchemeObject, name?: string): this;
addBasicAuth(options?: SecuritySchemeObject, name?: string): this;
addCookieAuth(cookieName?: string, options?: SecuritySchemeObject, securityName?: string): this;
build(): Omit<OpenAPIObject, 'paths'>;
}

SwaggerModule.createDocument 返回的是一个符合OpenAPI规范的 OpenAPIObject 可序列化对象,不仅可以通过 HTTP 进行托管,还可以将其保存为 JSON/YAML 文件,并以不同的方式使用。

SwaggerModule类
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
class SwaggerModule {
private static readonly metadataLoader;
static createDocument(app: INestApplication, config: Omit<OpenAPIObject, 'paths'>, options?: SwaggerDocumentOptions): OpenAPIObject;
static loadPluginMetadata(metadataFn: () => Promise<Record<string, any>>): Promise<void>;
private static serveStatic;
private static serveDocuments;
static setup(path: string, app: INestApplication, documentOrFactory: OpenAPIObject | (() => OpenAPIObject), options?: SwaggerCustomOptions): void;
}
type OperationIdFactory = (controllerKey: string, methodKey: string, version?: string) => string;
// 文档选项,createDocument()方法的第三个参数
interface SwaggerDocumentOptions {
include?: Function[]; // 要包含在规范中的模块列表
extraModels?: Function[]; // 额外的模型,应该被检查并包含在规范中
ignoreGlobalPrefix?: boolean; // 如果为 `true`,Swagger 将忽略通过 `setGlobalPrefix()` 方法设置的全局前缀
deepScanRoutes?: boolean; // 如果为 `true`,Swagger 还将从 `include` 模块导入的模块中加载路由
operationIdFactory?: OperationIdFactory; // 用于生成操作 ID 的工厂函数
}
// 设置选项,setup()方法的第四个参数
interface SwaggerCustomOptions {
useGlobalPrefix?: boolean;
explorer?: boolean;
swaggerOptions?: SwaggerUiOptions;
customCss?: string;
customCssUrl?: string | string[];
customJs?: string | string[];
customJsStr?: string | string[];
customfavIcon?: string;
customSwaggerUiPath?: string;
swaggerUrl?: string;
customSiteTitle?: string;
validatorUrl?: string;
url?: string;
urls?: Record<'url' | 'name', string>[];
jsonDocumentUrl?: string;
yamlDocumentUrl?: string;
patchDocumentOnRequest?: <TRequest = any, TResponse = any>(req: TRequest, res: TResponse, document: OpenAPIObject) => OpenAPIObject;
}

Swagger 提供了许多 @Api*() 装饰器,用于描述 API,在Swagger UI中显示。

下面是常用的装饰器简介。详细文档

ApiTags分组

@ApiTags() 将控制器、路由方法分组。

1
2
3
@ApiTags('login')
@Controller('login')
export class LoginController {}

ApiOperation描述路由

@ApiOperation() 描述路由方法。

1
2
3
4
@ApiOperation({
summary: '创建登录', // 接口简介
description: '创建登录', // 接口描述
})

描述参数

有一些装饰器用于描述路由方法的参数,也就是接口所需的参数。作用于路由方法。

  1. @ApiParam() 描述动态路由参数。
  2. @ApiQuery() 描述查询字符串参数。
  3. @ApiBody() 描述请求体参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ApiParam描述动态路由参数
@Get()
@ApiParam({
name: 'id', // 参数名
description: '用户id', // 参数描述
required: true, // 是否必须
})
findOne(@Param('id', ParseIntPipe) id: number) {}

// ApiQuery描述查询字符串参数
@Get()
@ApiQuery({
name: 'page', // 参数名
description: '页码', // 参数描述
required: false, // 是否必须
})
findAll(@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number) {}

// ApiBody描述请求体参数
@Post()
@ApiBody({
type: CreateLoginDto, // 请求体类型
})
create(@Body() createLoginDto: CreateLoginDto) {}

ApiProperty描述属性

在Nest中使用DTO来约束、验证请求体的结构。使用 @ApiProperty() 描述DTO的属性。

ApiBodyApiProperty共同完成请求体的描述。有些时候ApiBody可以省略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CreateLoginDto {
@ApiProperty({
description: '用户名', // 属性描述
example: 'user1', // 示例
minLength: 3, // 最小长度
maxLength: 10, // 最大长度
required: true, // 是否必填
})
@IsNotEmpty() // 验证是否为空
@IsString() // 验证是否为字符串
@Length(3, 10, {
message: '用户名长度必须为3到10位', // 自定义错误信息
}) // 验证字符串长度
username: string;
@ApiProperty({
description: '密码',
example: '123456',
required: true,
})
@IsNotEmpty()
@IsString()
password: string;
}

通过 @ApiProperty() 可以设置属性的描述、示例、必填等信息,但对每个属性手动添加 @ApiProperty() 过于繁琐,且与 class-validator 的验证装饰器的意义重复。一旦拥有大量的DTO类,代码会变得冗长且难以维护。

Nest提供了CLI插件(Swagger插件)与 class-validator 结合使用,自动生成DTO描述。而无需手动添加 @ApiProperty()

CLI插件

CLI插件(Swagger插件)将自动执行以下操作:

  1. 除非使用@ApiHideProperty,否则会对所有DTO属性进行注解,使用@ApiProperty
  2. 根据问号(例如 name?: string)设置required属性(将设置为false)。
  3. 根据类型设置type或enum属性(还支持数组)。
  4. 根据分配的默认值设置default属性。
  5. 根据class-validator装饰器设置多个验证规则(如果将classValidatorShim设置为true)。
  6. 为每个具有适当状态和type(响应模型)的端点添加响应装饰器。
  7. 根据注释为属性和端点生成描述(如果将introspectComments设置为true)。
  8. 根据注释为属性生成示例值(如果将introspectComments设置为true)。

修改 nest-cli.json 配置文件,启用插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"compilerOptions": {
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true
}
}
]
}
}

options属性用于自定义插件的行为。

1
2
3
4
5
6
7
8
interface PluginOptions {
dtoFileNameSuffix?: string[]; // DTO文件名后缀
controllerFileNameSuffix?: string[]; // 控制器文件名后缀
classValidatorShim?: boolean; // 配合class-validator
dtoKeyOfComment?: string; // DTO注释的键名
controllerKeyOfComment?: string; // 控制器注释的键名
introspectComments?: boolean; // 是否解析注释
}

启用注释自省introspectComments功能后,CLI插件将根据注释为属性生成描述和示例值。

1
2
3
4
5
6
7
8
9
/**
* 用户名
* @example user1
*/
// 相当于
@ApiProperty({
description: `用户名`,
example: 'user1',
})

一些注意事项:

  1. 文件名必须为 .dto.ts.entity.ts 后缀,以便插件识别。也可以通过dtoFileNameSuffix自定义后缀名。
  2. 在更新插件选项时,请确保删除dist文件夹并重新构建应用程序。
  3. 即使启用了插件,仍然可以手动添加@ApiProperty()装饰器,以扩展覆盖插件生成的描述。这对于添加描述和示例值非常有用。
  4. 在DTO中使用映射类型工具(例如PartialType)时,应该从@nestjs/swagger导入,而不是@nestjs/mapped-types,以便插件能够获取模式信息。
  5. 如果不使用CLI,而是使用自定义的webpack配置,可以将此插件与ts-loader结合使用

ApiResponse描述响应

@ApiResponse() 描述路由方法的响应。

1
2
3
4
5
@ApiResponse({
status: 200, // 状态码
description: '成功', // 响应描述
type: String, // 响应体类型
})

Nest提供了一些简写的API响应装饰器,它们都继承自@ApiResponse装饰器

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
@ApiOkResponse()
@ApiCreatedResponse()
@ApiAcceptedResponse()
@ApiNoContentResponse()
@ApiMovedPermanentlyResponse()
@ApiFoundResponse()
@ApiBadRequestResponse()
@ApiUnauthorizedResponse()
@ApiNotFoundResponse()
@ApiForbiddenResponse()
@ApiMethodNotAllowedResponse()
@ApiNotAcceptableResponse()
@ApiRequestTimeoutResponse()
@ApiConflictResponse()
@ApiPreconditionFailedResponse()
@ApiTooManyRequestsResponse()
@ApiGoneResponse()
@ApiPayloadTooLargeResponse()
@ApiUnsupportedMediaTypeResponse()
@ApiUnprocessableEntityResponse()
@ApiInternalServerErrorResponse()
@ApiNotImplementedResponse()
@ApiBadGatewayResponse()
@ApiServiceUnavailableResponse()
@ApiGatewayTimeoutResponse()
@ApiDefaultResponse()

要为请求指定返回模型,必须创建一个DTO类,并将其传递给 type 属性。

1
2
3
4
5
6
7
8
9
10
11
12
export class CreateLoginResDto {
@ApiProperty({
description: '用户名', // 属性描述
example: 'user1', // 示例
})
username: string;
@ApiProperty({
description: '消息',
example: '登陆成功',
})
message: string;
}
1
2
3
4
5
@ApiResponse({
status: 200, // 状态码
description: '成功', // 响应描述
type: CreateLoginResDto, // 响应类型
})

ApiHeader描述请求头

@ApiHeader() 描述请求头。@ApiHeaders() 传入数组,描述多个请求头。

1
2
3
4
5
@ApiHeader({
name: 'Authorization', // 请求头名称
description: 'token', // 请求头描述
required: true, // 是否必须
})

全局参数

addGlobalParameters() 添加全局参数,它们将出现在每个路由方法的参数中。

1
2
3
4
5
6
new DocumentBuilder().addGlobalParameters({
name: 'Authorization',
description: 'token',
required: true,
in: 'header',
})

安全机制

@ApiSecurity() 定义特定操作应使用的安全机制

1
2
3
@ApiSecurity('basic')
@Controller('login')
export class LoginController {}

需要提前在 DocumentBuilder 中配置安全机制。

1
2
3
4
new DocumentBuilder().addSecurity('basic', {
type: 'http',
scheme: 'basic',
})

一些常用的身份验证机制是内置的,而不必手动定义,如basicbearer等。

  1. @ApiBasicAuth() 基本身份验证
  2. @ApiBearerAuth() Bearer身份验证
  3. @ApiOAuth2() OAuth2身份验证
  4. @ApiCookieAuth() Cookie身份验证

还需要在 DocumentBuilder 中添加安全定义。

1
2
3
4
5
6
7
8
9
10
11
@ApiBasicAuth()
new DocumentBuilder().addBasicAuth();

@ApiBearerAuth()
new DocumentBuilder().addBearerAuth();

@ApiOAuth2(['pets:write'])
new DocumentBuilder().addOAuth2();

@ApiCookieAuth()
new DocumentBuilder().addCookieAuth('optional-session-id');

swagger-typescript-api

swagger-typescript-api 可以根据 Swagger 规范生成接口的 TS 类型和axios/fetch请求函数。

该库提供了全局命令行工具,也可以导入为模块,使用 generateApi 方法生成接口文件。

1
2
3
4
5
6
7
8
9
10
11
12
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
// 生成接口
await generateApi({
name: 'api.ts',
output: resolve(process.cwd(), './api'),
url: 'http://localhost:3000/api-json',
httpClientType: 'axios',
});
}
bootstrap();

生成的接口文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/

export interface CreateLoginDto {
/**
* @minLength 3
* @maxLength 10
*/
username: string;
password: string;
}
// ......省略

JWT token

一个通用的后端通常使用JWT(JSON Web Token)进行会话控制、身份验证。至于session,详见session案例

NodeJS接口、会话控制-tokenJSON Web Token 入门教程-阮一峰

流程:服务端登陆接口验证账号密码,签发token,客户端携带token访问受保护的资源,服务端通过守卫验证token,放行受保护的路由。在token中保存角色身份,以实现基于角色的鉴权。

在Nest中使用JWT

安装 @nestjs/jwt 用于生成和验证JWT。文档

JwtModule 用于配置JWT。

src\auth\auth.module.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.register({
global: true, // 全局模块
secret: '123456', // 密钥,应该由环境变量或配置文件提供
// 签名选项
signOptions: {
expiresIn: 60 * 60 * 24, // 过期时间
},
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

JwtService 用于生成和验证JWT

  1. JwtService.sign() 生成JWT,Async为异步方法
  2. JwtService.verify() 验证JWT

AuthService 中使用 sign() 生成token,将用户名、角色、id等信息保存在token中。使用 verify() 验证token。

src\auth\auth.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}

async signIn(user: any) {
const payload = {
username: user.username, // 用户名
id: user.userId, // 用户id
role: user.role, // 用户角色
};
return {
access_token: await this.jwtService.signAsync(payload),
};
}

async verifyToken(token: string) {
return this.jwtService.verifyAsync(token, {
// 密钥,与JwtModule.register中的secret一致
// 应该从环境变量或配置文件中获取
secret: '123456',
});
}
}

测试登陆接口,获取token。实际应该通过账号密码,验证成功后生成token。

1
2
3
4
5
6
7
8
9
10
11
@Post('testLogin')
testLogin() {
return this.authService.signIn({
username: 'user1',
userId: 1,
role: 'admin',
});
}
// {
// "access_token": "eyJhbGciOiJIUzI..."
// }

生成后的token一般由客户端保存在本地,每次请求时携带token,以一定格式放在请求头 Authorization 字段中。

token放在请求头中
1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsI....

AuthGuard 守卫中调用 AuthService.verifyToken() 验证token。
并将用户信息存入请求对象,以便后续守卫(角色鉴权)、路由(获取用户信息)等使用。

src\auth\auth.guard.ts
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
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
// 验证token
const payload = await this.authService.verifyToken(token);
// 将用户信息存入请求对象,以便路由等使用
request['user'] = payload;
} catch (e) {
throw new UnauthorizedException();
}
return true;
}

// 从请求头中提取token
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.get('authorization')?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

测试受保护的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Get('list')
list(@Req() req: Request) {
console.log(req['user']);
// {
// username: 'user1',
// id: 1,
// role: 'admin',
// iat: 1708243192,
// exp: 1708329592
// }
return 'list';
}

config配置

一些关键的信息如jwt的加密密钥、数据库链接等,应该通过配置提供,而不是硬编码,12-Factor应用原则

在Node中,可以使用dotenv,将环境变量保存在.env文件中,通过process.env读取。

Nest提供了更好的做法,导入 ConfigModule 模块,使用 ConfigService 服务,该服务加载适当的 .env 文件,这样一个通用的配置模块Nest已经提供了@nestjs/config,其底层也使用了dotenv文档

安装:npm i -S @nestjs/config

键冲突:当一个键同时存在于运行时环境变量和 .env 文件中时,运行时环境变量优先。

使用ConfigModule

ConfigModule 模块用于加载配置文件,通常导入到根模块中,并设为全局模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
static forRoot(options?: ConfigModuleOptions): DynamicModule;
interface ConfigModuleOptions {
cache?: boolean; // 缓存环境变量
isGlobal?: boolean; // 全局模块
ignoreEnvFile?: boolean; // 忽略环境变量文件,只使用运行时环境变量
ignoreEnvVars?: boolean; // 忽略所有环境变量
envFilePath?: string | string[]; // 环境变量文件
validate?: (config: Record<string, any>) => Record<string, any>; // 自定义验证环境变量
validationSchema?: any; // 模式验证
validationOptions?: Record<string, any>; // 配置验证选项
load?: Array<ConfigFactory>; // 自定义加载配置文件
expandVariables?: boolean | DotenvExpandOptions; // 扩展变量
}

该模块使用 forRoot() 静态方法来控制其行为。

  1. isGlobal 是否全局模块
  2. envFilePath 环境变量文件
  3. ignoreEnvFile 忽略环境变量文件
  4. load 自定义加载配置文件
  5. expandVariables 扩展变量
  6. cache 缓存环境变量
  7. validationSchema 模式验证
  8. validationOptions 配置验证选项
  9. validate 自定义验证环境变量
配置案例
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
import { ConfigModule } from '@nestjs/config';
import configuration from './configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 全局模块
envFilePath: '.env', // 环境变量文件
// ignoreEnvFile: true, // 忽略环境变量文件
load: [configuration], // 自定义加载配置文件
expandVariables: true, // 扩展变量,允许在.env文件中嵌套变量
cache: true, // 缓存环境变量
// validationSchema: Joi.object({}), // 模式验证
// 配置验证选项
validationOptions: {
allowUnknown: false, // 是否允许环境变量中存在未知的键
abortEarly: true, // 早期中止, 一旦发现错误就停止验证
},
// 自定义验证,传入配置对象,返回配置对象
validate(config) {
return config;
},
}),
],
})
export class AppModule {}

env相关选项

envFilePath 用于指定环境变量文件,string | string[]

1
2
envFilePath: '.development.env',
envFilePath: ['.env.development.local', '.env.development'],

ignoreEnvFile 忽略环境变量文件,只使用运行时环境变量。
ignoreEnvVars 忽略所有环境变量。

expandVariables 启用扩展变量,允许在env文件中嵌套变量。

1
2
APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL} # 'support@mywebsite.com'

自定义配置文件

load 用于自定义加载配置文件,值为 ConfigFactory 数组,它是一个工厂函数,返回一个配置对象。

ConfigFactory 中可以访问到已经解析完环境变量的 process.env

src/configuration.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const configFactory = () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
},
jwt: {
secret: '123456',
expiresIn: 60 * 60 * 24,
},
});
// 导出配置类型,以便在其他地方使用,通过ReturnType获取函数返回类型
export type Configuration = ReturnType<typeof configFactory>;
// 需要导出一个工厂函数,返回配置对象
export default (): Configuration => configFactory;
1
2
import configuration from './configuration';
load: [configuration],

配置文件除了导出一个工厂函数外,还应该导出一个配置类型,以便在其他地方使用,获得类型提示。

注意:多个配置文件最终会合并为一个配置对象。要区分不同的配置文件,可以使用命名空间。

配置命名空间

load 允许加载多个配置文件,每个配置文件可以返回一个对象,这些对象将被合并到一个配置对象中。

为了避免冲突,可以通过 registerAs() 为每个配置文件指定一个命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { registerAs } from '@nestjs/config';
const configFactory = () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
},
jwt: {
secret: '123456',
expiresIn: 60 * 60 * 24,
},
});
// 导出配置类型,以便在其他地方使用,通过ReturnType获取函数返回类型
export type Configuration = ReturnType<typeof configFactory>;
// 导出命名空间token(名称)
export const configToken = 'configuration';
// 使用registerAs为该配置文件创建一个命名空间
export default registerAs(configToken, configFactory);

使用ConfigService

ConfigService 服务使用 get() 读取配置对象中的值。

对于普通的 ConfigFactory 配置文件,可以直接获取配置对象的属性。
对于 registerAs() 创建的具有命名空间的配置,需要先获取命名空间,再获取属性,或者使用点表示法获取属性。

下面以jwt配置为例。将jwt的密钥通过配置对象获取。

src\auth\auth.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Configuration, configToken } from 'src/configuration';

@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
// 注入ConfigService
private readonly configService: ConfigService,
) {}

async verifyToken(token: string) {
return this.jwtService.verifyAsync(token, {
// 通过配置对象获取密钥
secret: this.configService.get<Configuration>(configToken).jwt.secret,
});
}
}

对于 JwtModule 的配置,也可以通过 ConfigService 获取。

为了注入 ConfigService,需使用 JwtModule.registerAsync() 异步配置方法。问题参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Configuration, configToken } from 'src/configuration';

@Module({
imports: [
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret: configService.get<Configuration>(configToken).jwt.secret,
signOptions: {
expiresIn:
configService.get<Configuration>(configToken).jwt.expiresIn,
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

get() 方法还可以接收第二个参数,用于定义默认值。当键不存在时,将返回该默认值。

ConfigService 可以接收两个泛型

  1. 第一个泛型用于防止访问不存在的配置属性
  2. 第二个泛型为boolean,以消除tsconfig选项 strictNullChecks 打开时,可能返回的所有undefined类型
1
2
3
4
5
6
7
8
interface EnvironmentVariables {
PORT: number;
TIMEOUT: string;
}
constructor(private configService: ConfigService<EnvironmentVariables>) {
const port = this.configService.get('PORT', { infer: true });
// infer: true 根据接口自动推断属性的类型
}

validate验证环境变量

validate(config)同步的,参数为所有环境变量,用于验证所需的环境变量是否符合某些验证规则,防止预期之外的环境变量值被传入配置对象。

与自定义类验证管道类似,使用 class-validatorclass-transformer 库。

编写验证类和验证函数
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
import { plainToInstance } from 'class-transformer';
import { IsDefined, IsNumber, IsString, validateSync } from 'class-validator';

class EnvironmentVariables {
@IsNumber()
PORT?: number;
@IsString()
@IsDefined()
DATABASE_HOST: string;
@IsNumber()
DATABASE_PORT?: number;
}

export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true, // 启用隐式转换,这对于环境变量来说是必要的
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: true, // 是否跳过缺失的属性,关闭后所有所需的环境变量都必须存在
});

if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

因为 validate()同步的,所以必须使用 validateSync() 同步方法。

应用验证函数
1
2
3
4
import { validate } from './config.validation';
ConfigModule.forRoot({
validate,
}),

若验证失败,则会直接抛出错误,并终止应用程序。

1
2
3
4
5
6
7
8
Error: An instance of EnvironmentVariables has failed the validation:    
- property NODE_ENV has failed the following constraints: isEnum
,An instance of EnvironmentVariables has failed the validation:
- property PORT has failed the following constraints: isNumber
,An instance of EnvironmentVariables has failed the validation:
- property DATABASE_HOST has failed the following constraints: isNumber
,An instance of EnvironmentVariables has failed the validation:
- property DATABASE_PORT has failed the following constraints: isNumber

在main中使用

main.ts 中通过 app.get() 获取已存在的 ConfigService 实例引用

1
2
const configService = app.get(ConfigService);
await app.listen(configService.get<Configuration>(configToken).port);

局部注册配置文件

一些配置文件可能只在特定模块中使用,可以使用 forFeature() 方法注册配置文件。而不必将所有配置文件都在 forRoot() 中注册。

1
2
3
4
5
6
7
8
9
10
export default registerAs('db', () => ({
s1: 222,
s2: 111,
}));

import dbConfig from './db.configuration';
@Module({
imports: [ConfigModule.forFeature(dbConfig)],
})
export class DbModule {}

在该模块的控制器中访问配置。

1
2
3
4
5
@Get()
findAll() {
console.log(this.configService.get('db').s1);
return this.dbService.findAll();
}

统一注册和局部注册效果是差不多的,都能在所需的地方通过注入 ConfigService 来访问,只是局部注册需要注意模块依赖和模块的初始化顺序。

若在依赖该模块的模块中访问局部注册的配置,可能需要使用 onModuleInit() 钩子,而不是在构造函数中,因为 forFeature() 方法在模块初始化期间运行,而模块初始化的顺序是不确定的,这些配置所依赖的模块可能尚未初始化。onModuleInit() 方法仅在所有依赖的模块都已初始化后才运行,因此是安全的。

所以配置文件不是太多的话,还是统一注册比较方便。