Logger日志
node服务端常用的日志收集工具:winston、log4js、bunyan、npmlog等
Nest内置了一个基于文本的日志记录器:Logger
类结构:
LoggerService是接口标准,如果要替换掉内置的日志类,最低要求是得符合这个接口。ConsoleLogger日志主要业务类,主要负责处理格式化日志字符串输出。Logger更高层封装,加入了输出缓存,并统一管理日志等级。自身有一个ConsoleLogger的默认单例。
配置Logger
在创建Nest应用时,通过NestApplicationOptions.logger来配置Logger,用于系统日志记录。
注意:这些配置不会影响到自定义Logger,只作用于内置Logger类。
logger?: LoggerService | LogLevel[] | false;设置为false时,将禁用Logger日志
const app = await NestFactory.create(AppModule, { logger: false,});还可以指定输出的最低日志等级,优先级更高的也将被输出。
const app = await NestFactory.create(AppModule, { logger: ['log'], // log及以上等级的日志将被输出,如warn、error});Logger默认提供了6个日志等级
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
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类,覆盖需要扩展的方法
// class ConsoleLogger implements LoggerService {}class MyLogger extends ConsoleLogger { error(message: any, stack?: string, context?: string) { super.error(...arguments); }}在Nest应用中使用自定义Logger,用于系统日志记录
const app = await NestFactory.create(AppModule, { logger: new MyLogger(), // 使用自定义Logger // logger: console // 使用console对象});可以通过 set*() 等实例方法配置自定义Logger,如 setLogLevels() 设置输出的最低日志等级。
this.logger.setLogLevels(['error']);日志格式
Logger默认的日志格式如下:
[AppName] [PID] [Timestamp] [LogLevel] [Context] Message [+ms]- AppName 应用程序名,被固定为[Nest]
- PID:系统分配的进程编号
- Timestamp:当前日志输出的格式化系统时间
- LogLevel:日志等级文本
- Context:上下文
- Message:输出的消息,可以是对象类型输出
- +ms:两次输出日志的时间间隔,时间戳
使用Logger
通过Logger类手动实例化一个Logger对象,用于输出日志,还可以使用app.useLogger()
class Logger implements LoggerService { constructor(context: string, options?: { timestamp?: boolean; });}其构造函数接受两个参数:
- context:上下文,用于标识日志输出的位置
- options:配置项,只有一个
timestamp属性,表示是否输出 +ms,默认为false
其日志方法接收两个参数:
- message:输出的消息,可以是对象类型,配合
format()方法,用于格式化输出多个消息 - context:上下文,用于标识日志输出的位置。
**注意:**当Logger实例也配置了context时,以Logger实例的context为准,并且会在输出的日志中追加一条日志方法的context信息。
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实例。
import { Module } from '@nestjs/common';import { MyLoggerService } from './my-logger.service';
@Module({ providers: [MyLoggerService], exports: [MyLoggerService],})export class MyLoggerModule {}import { ConsoleLogger, Injectable } from '@nestjs/common';
@Injectable()export class MyLoggerService extends ConsoleLogger {}设置为全局模块,或在需要的地方导入使用,使用 setContext() 设置上下文。
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 日志缓冲。
const app = await NestFactory.create(AppModule, { bufferLogs: true,});// app.get()调用检索MyLoggerService的单例实例,并依赖于首先在另一个模块中注入该实例app.useLogger(app.get(MyLoggerService));app.get() 可以检索某个类型的单例实例,依赖于首先在另一个模块中注入该实例。无需再重复实例化。
ModuleRef模块引用
ModuleRef 类用于检索模块中的提供者实例,并使用注入令牌作为查找键名来获取实例的引用。
ModuleRef 可以通过常规方法注入到类中。
import { ModuleRef } from '@nestjs/core';class AppController { constructor(private readonly moduleRef: ModuleRef) {}}获取实例
ModuleRef.get() 使用注入标记/类名检索当前模块中存在(已实例化)的提供者、控制器或可注入项(例如,守卫、拦截器等)
若该提供者是其它模块定义并暴露的,需要将strict设置为false,从全局上下文中检索。
import { UserService } from './user/user.service';moduleRef.get(UserService, { strict: false });ModuleRef 无视了模块的依赖关系,能直接从全局上下文中检索提供者实例。
若需要另一个模块的提供者实例,可以不暴露该提供者、不导入该模块,直接通过ModuleRef检索。
@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)。
多次调用该方法,返回的是不同的实例。
const transientServices = await Promise.all([ this.moduleRef.resolve(TransientService), this.moduleRef.resolve(TransientService),]);console.log(transientServices[0] === transientServices[1]); // false为了在多个 resolve() 调用之间生成单个实例,可以通过 ContextIdFactory 创建一个上下文标识,并传递给 resolve() 方法。相同的上下文标识,返回的是相同的实例。
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 依赖注入系统实例化和管理的。
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() 方法解析请求作用域提供者。
const contextId = ContextIdFactory.create();this.moduleRef.registerRequestByContextId(/* YOUR_REQUEST_OBJECT */, contextId);await this.moduleRef.resolve(TransientService, contextId),若在请求提供者中解析另一个请求提供者,可以更方便地通过 getByRequest() 方法基于请求对象创建一个上下文标识,并将其传递给 resolve() 调用:
const contextId = ContextIdFactory.getByRequest(this.request);await this.moduleRef.resolve(TransientService, contextId);动态实例化
ModuleRef.create() 动态实例化一个之前没有注册为提供者的类。
即使该类已经被注册为提供者且已经实例化,也会创建一个新的实例(Promise)。
await this.moduleRef.create(oneService);与直接 new 不同,create() 会处理依赖注入,但这些依赖必须是执行 create() 所在的模块中能访问到的,缺失的依赖需要导入到该模块中。
生命周期事件
Nest 应用以及每个应用元素都有一个由 Nest 管理的生命周期。文档
Nest 提供了许多钩子方法,用于在生命周期的不同阶段执行自定义逻辑。
onModuleInit()一旦解决了模块的依赖,就会调用。onApplicationBootstrap()在所有模块初始化后调用,但在监听连接之前。onModuleDestroy()* 在收到终止信号(如 SIGINT)或app.close()后调用。beforeApplicationShutdown()* 在所有 onModuleDestroy 完成后调用。onApplicationShutdown()* 在连接关闭后调用。
**注意:**请求作用域的类没有生命周期钩子。它们专门为每个请求创建,并在发送响应后自动进行垃圾回收。
*若没有显式调用 app.close(),则三个关闭钩子默认是不会监听系统信号(如 SIGINT)的,监听系统信号会消耗较多的系统资源。可以通过 app.enableShutdownHooks() 启用对系统信号的监听,但信号在windows上可能无法预期地工作。
使用钩子
每个生命周期钩子都由一个接口表示。
以 onModuleInit() 为例:需要实现 OnModuleInit 接口。
@Injectable()export class UserService implements OnModuleInit { onModuleInit() { console.log(`The UserService has been initialized.`); }}如果生命周期钩子返回一个 Promise,Nest 将等待这个 Promise 完成(或者解决)之后再继续生命周期。
所以onModuleInit()和onApplicationBootstrap()钩子可以是异步的,以推迟模块的初始化,可以完成如数据库连接等异步工作后,在完成应用初始化、监听连接。
class UserService implements OnModuleInit { async onModuleInit(): Promise<void> { return await new Promise((resolve) => { setTimeout(() => { console.log('初始化完成'); resolve(); }, 3000); }); }}整体案例:
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); }}onModuleInit.onApplicationBootstrap down.[Nest] 20808 - 2024/02/17 18:03:51 LOG [NestApplication] Nest application successfully startedonModuleDestroybeforeApplicationShutdown: SIGINTOnApplicationShutdown: SIGINTswagger接口文档
Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务的接口文档。
Swagger UI 用于将 Swagger 规范生成的文档呈现为交互式的、动态的 API 文档。
在Nset中使用需安装@nestjs/swagger,npm i -S @nestjs/swagger,文档
使用 SwaggerModule 类初始化 Swagger 文档,DocumentBuilder 类配置文档。
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 规范的基本文档。
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 文件,并以不同的方式使用。
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() 将控制器、路由方法分组。
@ApiTags('login')@Controller('login')export class LoginController {}ApiOperation描述路由
@ApiOperation() 描述路由方法。
@ApiOperation({ summary: '创建登录', // 接口简介 description: '创建登录', // 接口描述})描述参数
有一些装饰器用于描述路由方法的参数,也就是接口所需的参数。作用于路由方法。
@ApiParam()描述动态路由参数。@ApiQuery()描述查询字符串参数。@ApiBody()描述请求体参数。
// 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的属性。
ApiBody和ApiProperty共同完成请求体的描述。有些时候ApiBody可以省略。
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插件)将自动执行以下操作:
- 除非使用
@ApiHideProperty,否则会对所有DTO属性进行注解,使用@ApiProperty。 - 根据问号(例如 name?: string)设置required属性(将设置为false)。
- 根据类型设置type或enum属性(还支持数组)。
- 根据分配的默认值设置default属性。
- 根据
class-validator装饰器设置多个验证规则(如果将classValidatorShim设置为true)。 - 为每个具有适当状态和type(响应模型)的端点添加响应装饰器。
- 根据注释为属性和端点生成描述(如果将
introspectComments设置为true)。 - 根据注释为属性生成示例值(如果将
introspectComments设置为true)。
修改 nest-cli.json 配置文件,启用插件。
{ "compilerOptions": { "plugins": [ { "name": "@nestjs/swagger", "options": { "classValidatorShim": true, "introspectComments": true } } ] }}options属性用于自定义插件的行为。
interface PluginOptions { dtoFileNameSuffix?: string[]; // DTO文件名后缀 controllerFileNameSuffix?: string[]; // 控制器文件名后缀 classValidatorShim?: boolean; // 配合class-validator dtoKeyOfComment?: string; // DTO注释的键名 controllerKeyOfComment?: string; // 控制器注释的键名 introspectComments?: boolean; // 是否解析注释}启用注释自省introspectComments功能后,CLI插件将根据注释为属性生成描述和示例值。
/** * 用户名 * @example user1 */// 相当于@ApiProperty({ description: `用户名`, example: 'user1',})一些注意事项:
- 文件名必须为
.dto.ts或.entity.ts后缀,以便插件识别。也可以通过dtoFileNameSuffix自定义后缀名。 - 在更新插件选项时,请确保删除dist文件夹并重新构建应用程序。
- 即使启用了插件,仍然可以手动添加
@ApiProperty()装饰器,以扩展或覆盖插件生成的描述。这对于添加描述和示例值非常有用。 - 在DTO中使用映射类型工具(例如
PartialType)时,应该从@nestjs/swagger导入,而不是@nestjs/mapped-types,以便插件能够获取模式信息。 - 如果不使用CLI,而是使用自定义的webpack配置,可以将此插件与
ts-loader结合使用
ApiResponse描述响应
@ApiResponse() 描述路由方法的响应。
@ApiResponse({ status: 200, // 状态码 description: '成功', // 响应描述 type: String, // 响应体类型})Nest提供了一些简写的API响应装饰器,它们都继承自@ApiResponse装饰器
@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 属性。
export class CreateLoginResDto { @ApiProperty({ description: '用户名', // 属性描述 example: 'user1', // 示例 }) username: string; @ApiProperty({ description: '消息', example: '登陆成功', }) message: string;}@ApiResponse({ status: 200, // 状态码 description: '成功', // 响应描述 type: CreateLoginResDto, // 响应类型})ApiHeader描述请求头
@ApiHeader() 描述请求头。@ApiHeaders() 传入数组,描述多个请求头。
@ApiHeader({ name: 'Authorization', // 请求头名称 description: 'token', // 请求头描述 required: true, // 是否必须})全局参数
addGlobalParameters() 添加全局参数,它们将出现在每个路由方法的参数中。
new DocumentBuilder().addGlobalParameters({ name: 'Authorization', description: 'token', required: true, in: 'header',})安全机制
@ApiSecurity() 定义特定操作应使用的安全机制
@ApiSecurity('basic')@Controller('login')export class LoginController {}需要提前在 DocumentBuilder 中配置安全机制。
new DocumentBuilder().addSecurity('basic', { type: 'http', scheme: 'basic',})一些常用的身份验证机制是内置的,而不必手动定义,如basic、bearer等。
@ApiBasicAuth()基本身份验证@ApiBearerAuth()Bearer身份验证@ApiOAuth2()OAuth2身份验证@ApiCookieAuth()Cookie身份验证
还需要在 DocumentBuilder 中添加安全定义。
@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 方法生成接口文件。
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();生成的接口文件:
/* 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接口、会话控制-token、JSON Web Token 入门教程-阮一峰
**流程:**服务端登陆接口验证账号密码,签发token,客户端携带token访问受保护的资源,服务端通过守卫验证token,放行受保护的路由。在token中保存角色身份,以实现基于角色的鉴权。
在Nest中使用JWT
安装 @nestjs/jwt 用于生成和验证JWT。文档
JwtModule 用于配置JWT。
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
JwtService.sign()生成JWT,Async为异步方法JwtService.verify()验证JWT
在 AuthService 中使用 sign() 生成token,将用户名、角色、id等信息保存在token中。使用 verify() 验证token。
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。
@Post('testLogin')testLogin() { return this.authService.signIn({ username: 'user1', userId: 1, role: 'admin', });}// {// "access_token": "eyJhbGciOiJIUzI..."// }生成后的token一般由客户端保存在本地,每次请求时携带token,以一定格式放在请求头 Authorization 字段中。
Authorization: Bearer eyJhbGciOiJIUzI1NiIsI....在 AuthGuard 守卫中调用 AuthService.verifyToken() 验证token。
并将用户信息存入请求对象,以便后续守卫(角色鉴权)、路由(获取用户信息)等使用。
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; }}测试受保护的路由
@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 模块用于加载配置文件,通常导入到根模块中,并设为全局模块。
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() 静态方法来控制其行为。
isGlobal是否全局模块envFilePath环境变量文件ignoreEnvFile忽略环境变量文件load自定义加载配置文件expandVariables扩展变量cache缓存环境变量validationSchema模式验证validationOptions配置验证选项validate自定义验证环境变量
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[]。
envFilePath: '.development.env',envFilePath: ['.env.development.local', '.env.development'],ignoreEnvFile 忽略环境变量文件,只使用运行时环境变量。
ignoreEnvVars 忽略所有环境变量。
expandVariables 启用扩展变量,允许在env文件中嵌套变量。
APP_URL=mywebsite.comSUPPORT_EMAIL=support@${APP_URL} # 'support@mywebsite.com'自定义配置文件
load 用于自定义加载配置文件,值为 ConfigFactory 数组,它是一个工厂函数,返回一个配置对象。
在 ConfigFactory 中可以访问到已经解析完环境变量的 process.env。
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;import configuration from './configuration';load: [configuration],配置文件除了导出一个工厂函数外,还应该导出一个配置类型,以便在其他地方使用,获得类型提示。
**注意:**多个配置文件最终会合并为一个配置对象。要区分不同的配置文件,可以使用命名空间。
配置命名空间
load 允许加载多个配置文件,每个配置文件可以返回一个对象,这些对象将被合并到一个配置对象中。
为了避免冲突,可以通过 registerAs() 为每个配置文件指定一个命名空间。
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的密钥通过配置对象获取。
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() 异步配置方法。问题参考
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 可以接收两个泛型
- 第一个泛型用于防止访问不存在的配置属性
- 第二个泛型为boolean,以消除tsconfig选项
strictNullChecks打开时,可能返回的所有undefined类型
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-validator 和 class-transformer 库。
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() 同步方法。
import { validate } from './config.validation';ConfigModule.forRoot({ validate,}),若验证失败,则会直接抛出错误,并终止应用程序。
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 实例引用
const configService = app.get(ConfigService);await app.listen(configService.get<Configuration>(configToken).port);局部注册配置文件
一些配置文件可能只在特定模块中使用,可以使用 forFeature() 方法注册配置文件。而不必将所有配置文件都在 forRoot() 中注册。
export default registerAs('db', () => ({ s1: 222, s2: 111,}));
import dbConfig from './db.configuration';@Module({ imports: [ConfigModule.forFeature(dbConfig)],})export class DbModule {}在该模块的控制器中访问配置。
@Get()findAll() { console.log(this.configService.get('db').s1); return this.dbService.findAll();}统一注册和局部注册效果是差不多的,都能在所需的地方通过注入 ConfigService 来访问,只是局部注册需要注意模块依赖和模块的初始化顺序。
若在依赖该模块的模块中访问局部注册的配置,可能需要使用 onModuleInit() 钩子,而不是在构造函数中,因为 forFeature() 方法在模块初始化期间运行,而模块初始化的顺序是不确定的,这些配置所依赖的模块可能尚未初始化。onModuleInit() 方法仅在所有依赖的模块都已初始化后才运行,因此是安全的。
所以配置文件不是太多的话,还是统一注册比较方便。
评论