Nestjs 学习记录:(五)Logging 日志管理

458 阅读9分钟

Nestjs 为我们提供了一个内置的基于文本的日志记录工具,可以在应用程序启动和其他,比如打印捕获的异常(即系统日志记录)等场景下使用。日志功能通过 @nestjs/common 包中的 Logger 类实现,开发者可以完全控制以下任意一种日志系统的行为:

  • 完全禁用日志功能
  • 指定日志信息的级别(报错、警告、排调试等)
  • 重写默认日志中的时间戳格式
  • 完全重写默认的日志系统
  • 通过继承自定义默认日志格式
  • 使用依赖注入,简化组合和测试你的应用程序

当然,开发者可以使用 Nestjs 内置的日志工具,也可以自行选择其他合适的第三方日志工具以实现一套完全自定义的生产级别的日志系统。

一、Built-in Logger

在我们使用 NestFactory.create() 创建自己的 Nestjs 应用时,可以在第二个参数中配置 logger: false 来控制内置的日志系统的行为。开发者可以给该属性提供不同类型的值实现不同的效果:

第一种:Boolean -- 控制是否启用日志系统

const app = await NestFactory.create(AppModule, {
  // 关闭默认的日志工具
  logger: false
});
await app.listen(3000);

第二种:String[] -- 指定日志级别列表

const app = await NestFactory.create(AppModule, {
  logger: ['error', 'warn']
});
await app.listen(3000);

目前,Nestjs 支持的日志级别包括以下几种:

  • log
  • fatal
  • error
  • warn
  • debug
  • verbose

第三种:Class -- 提供自定义类完全重写内置日志系统的打印方法

const app = await NestFactory.create(AppModule, {
  logger: new LoggerService()
});
await app.listen(3000);
import { LoggerService } from '@nestjs/common';
​
export class MyLoggerService implements LoggerService {
  /**
   * 打印不同级别的日志信息
   */
  log(message: any, ...optionalParams: any[]) {}
​
  fatal(message: any, ...optionalParams: any[]) {}
​
  error(message: any, ...optionalParams: any[]) {}
​
  warn(message: any, ...optionalParams: any[]) {}
​
  debug?(message: any, ...optionalParams: any[]) {}
​
  verbose?(message: any, ...optionalParams: any[]) {}
}

二、Dependency Injection

为了实现更加强大的日志功能,例如您可能会希望使用依赖注入,将你的自定义日志工具注入到其他 Controller 或 Provider 中来更加自由地控制行为。为了启用依赖注入,我们需要创建一个 MyLoggerService类,并将该类注册为某个 Module 中的 Provider 以充分利用 Nestjs 的依赖注入机制。

关于 Providers 和 Modules 的介绍可以参考本专栏的另外两篇文章

Providers:juejin.cn/post/729463…

Modules:juejin.cn/post/729488…

言归正传,我们需要编写结构如下的 LoggerModule:

import { Module } from '@nestjs/common';
import { MyLoggerService } from './logger.service';
​
@Module({
  providers: [MyLoggerService],
  exports: [MyLoggerService]
})
export class LoggerModule {}

现在,你可以全局/局部注入该模块到任何其他模块中。同时,因为你的 MyLoggerService是作为 LoggerModule 的其中一个 Provider 存在,也可以手动注入特殊的 Provider(比如 ConfigService)到 LoggerModule 中让日志服务也可以读取到项目当前运行环境的环境变量。

如果你希望使用 logger: new MyLoggerService() 将自定义logger注入到全局,需要注意的一点是,全局应用的实例化过程并不会参与到任何模块上下文的依赖注入阶段,也就是说,Nestjs 可能会不知道什么时候该实例化我们的日志工具类。

因此,为了保证至少有一个模块导入了 MyLoggerService 来触发 Nestjs 进行实例化操作,我们需要指示 Nestjs 按下面的结构使用相同的 MyLoggerService 单例实例:

const app = await NestFactory.create(AppModule, {
  bufferLogs: true
});
app.useLogger(app.get(MyLoggerService));
await app.listen(3000);

bufferLogs: true -- 确保所有的日志直到我们的自定义 logger 被绑定到应用的实例化进程【无论成功或失败】前都能被缓存

app.get() -- 检索到 MyLoggerService 实例

三、Using the logger for application logging

第一步:创建 MyLoggerService 类

import { Injectable, Scope, ConsoleLogger } from '@nestjs/common';
​
@Injectable()
// 我们需要继承 ConsoleLogger 类并添加自定义方法 customLog
export class MyLoggerService extends ConsoleLogger {
  customLog() {
    this.log('Please feed the cat!');
  }
}

第二步:创建 LoggerModule,并将我们编写的 MyLoggerService 注入

import { Module } from '@nestjs/common';
import { MyLoggerService  } from './my-logger.service';
​
@Module({
  providers: [MyLoggerService],
  exports: [MyLoggerService],
})
export class LoggerModule {}

第三步:全局注入 LoggerService

const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
});
app.useLogger(new MyLoggerService());
await app.listen(3000);

第四步:在 CatsService 中使用日志服务

import { Injectable } from '@nestjs/common';
import { MyLoggerService  } from './my-logger.service';

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

  constructor(private myLogger: MyLoggerService) {}

  findAll(): Cat[] {
    // 你可以调用日志的所有默认或自定义方法
    this.myLogger.warn('About to return cats!');
    this.myLogger.customLog();
    return this.cats;
  }
}

四、External Logger

上述章节中,我们初步了解到 Nestjs 的日志服务的注入逻辑及使用方法,在实际开发过程中,Nestjs 的内置日志工具可以帮助我们监视系统的行为,并将格式化后的日志文本打印出来。

但是,生产级应用程序通常会有更加特殊的日志记录需求,包括高级过滤、格式化和集中日志记录等,通常我们会利用专用的日志库,比如 Winston 等,与任何标准的 Node.js 应用程序一样,开发者可以充分利用这些三方模块来编写更加强大的日志管理工具。

接下来,我们用相对更加具体的示例来演示如何用 Winston 来为自己的项目配置日志工具【基本步骤与第三章差别不大,部分实现细节上会略有不同】:

第一步:创建 LoggerService 类

    // logger/logger.service.ts
    import { Logger, createLogger, format, transports } from 'winston'
    import 'winston-daily-rotate-file'
    import { Injectable } from '@nestjs/common'
    
    @Injectable()
    export class LoggerService {
      private context?: string
      private logger: Logger
    
      public setContext(context: string): void {
        this.context = context
      }
    
      constructor() {
        this.logger = createLogger({
          // winston 格式定义
          format: format.combine(
            format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
            format.prettyPrint()
          ),
          // 生成文件
          // winston 文档中使用的方法为 new transports.File()
          // 因为加入日志归档等相关功能,所以使用transports.DailyRotateFile()方法来实现
          transports: [
            // 打印到控制台,生产可注释关闭该功能
            new transports.Console(),
            // 保存到文件
            new transports.DailyRotateFile({
              // 日志文件文件夹,建议使用path.join()方式来处理,或者process.cwd()来设置,此处仅作示范
              dirname: 'src/logs',
              // 日志文件名 %DATE% 会自动设置为当前日期
              filename: 'application-%DATE%.info.log',
              // 日期格式
              datePattern: 'YYYY-MM-DD',
              // 压缩文档,用于定义是否对存档的日志文件进行 gzip 压缩 默认值 false
              zippedArchive: true,
              // 文件最大大小,可以是bytes、kb、mb、gb
              maxSize: '20m',
              // 最大文件数,可以是文件数也可以是天数,天数加单位"d",
              maxFiles: '7d',
              // 格式定义,同winston
              format: format.combine(
                format.timestamp({
                  format: 'YYYY-MM-DD HH:mm:ss'
                }),
                format.json()
              ),
              // 日志等级,不设置所有日志将在同一个文件
              level: 'info'
            }),
            // 同上述方法,区分error日志和info日志,保存在不同文件,方便问题排查
            new transports.DailyRotateFile({
              dirname: 'src/logs',
              filename: 'application-%DATE%.error.log',
              datePattern: 'YYYY-MM-DD',
              zippedArchive: true,
              maxSize: '20m',
              maxFiles: '14d',
              format: format.combine(
                format.timestamp({
                  format: 'YYYY-MM-DD HH:mm:ss'
                }),
                format.json()
              ),
              level: 'error'
            })
          ]
        })
      }
    
      // 错误日志记录
      error(ctx: any, message: string, meta?: Record<string, any>): Logger {
        return this.logger.error({
          message,
          contextNmae: this.context,
          ctx,
          ...meta
        })
      }
      // 警告日志记录
      warn(ctx: any, message: string, meta?: Record<string, any>): Logger {
        return this.logger.warn({
          message,
          contextNmae: this.context,
          ctx,
          ...meta
        })
      }
      // debug日志记录
      debug(ctx: any, message: string, meta?: Record<string, any>): Logger {
        return this.logger.debug({
          message,
          contextNmae: this.context,
          ctx,
          ...meta
        })
      }
      // 基本日志记录
      info(ctx: any, message: string, meta?: Record<string, any>): Logger {
        return this.logger.info({
          message,
          contextNmae: this.context,
          ctx,
          ...meta
        })
      }
    }

第二步:创建 LoggerModule,并将我们编写的 LoggerService 注入

    // logger/logger.module.ts
    import { Global, Module } from '@nestjs/common'
    import { LoggerService } from './logger.service'
    
    // 注意这里的 @Global()
    // LoggerModule 会被 @Global() 标记为全局模块,以后在其他功能模块中使用 LoggerService 时无需再手动注入
    @Global()
    @Module({
      providers: [LoggerService],
      exports: [LoggerService]
    })
    export class LoggerModule {}

第三步:全局注入 LoggerModule

    // app.module.ts
    import { Module } from '@nestjs/common'
    import {
      MongoModule,
      LoggerModule,
      UserModule
    } from './modules'
    import { AppController } from './app.controller'
    import { AppService } from './app.service'
    
    @Module({
      imports: [
        // 数据库连接
        MongoModule,
        // 日志服务
        LoggerModule,
        // 用户模块
        UserModule,
      ],
      controllers: [AppController],
      providers: [AppService]
    })
    export class AppModule {}

<!---->

    // main.ts
    import { NestFactory } from '@nestjs/core'
    import { AppModule } from './app.module'
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule, {
        // 关闭默认的日志服务
        logger: false,
        cors: true
      })
    
      await app.listen(3000)
    }
    bootstrap()

第四步:在 UserService 中使用日志服务

    import { Model } from 'mongoose'
    import { InjectModel } from '@nestjs/mongoose'
    import { Injectable } from '@nestjs/common'
    import { JwtService } from '@nestjs/jwt'
    import { LoggerService } from '@/modules/logger/logger.service'
    import type { ApiResponse } from '@/interface/response.interface'
    import { DefaultUserEntity, UserSignUp, UserEntity } from './DTO/user.dto'
    
    @Injectable()
    export class UserService {
      // 定义 response 实例【ApiResponse 类型接口的定义参考实际项目中前后端约定的接口响应规范】
      private response: ApiResponse
      
      constructor(
        @InjectModel(UserEntity.name) private userModel: Model<UserEntity>,
        // “注入” LoggerService 实例
        private readonly logger: LoggerService,
        private readonly jwtService: JwtService
      ) {}
    
      /**
       * @description 查找数据库中符合该 accountId 的用户
       * @param accountId 账户ID
       * @returns 查询结果
       */
      async findOneByName(user: { username: string }): Promise<UserEntity[]> {
        return await this.userModel.find({ username: user.username })
      }
    
      /**
       * @description 用户注册接口
       * @param user 用户的部分信息
       */
      async signup(user: UserSignUp): Promise<ApiResponse> {
        const res = await this.findOneByName(user)
        if (res && res.length) {
          // 调用 logger 里定义的 error 方法,打印异常日志
          this.logger.error('UserService', '用户已注册')
          this.response = {
            Code: -1,
            Message: '用户已注册',
            ReturnData: null
          }
          return this.response
        }
        try {
          const createUser = await this.userModel.create({
            ...DefaultUserEntity,
            ...user,
            avatar: `http://localhost:3000/static/avatar/avatar_${
              Math.floor(Math.random() * 5) + 1
            }.png`
          })
          await createUser.save()
          // 调用 logger 里定义的 info 方法,打印消息日志
          this.logger.info(null, '新增用户成功')
          this.response = {
            Code: 1,
            Message: '新增成功',
            ReturnData: user.username
          }
        } catch (err) {
          this.logger.error(null, err)
          this.response = {
            Code: -1,
            Message: '用户新增失败,请联系负责人核对',
            ReturnData: err
          }
        }
        return this.response
      }
    
      /**
       * @description 用户登录接口
       * @param user 用户的部分信息
       */
      async login(user: UserSignUp): Promise<ApiResponse> {
        const res = await this.findOneByName(user)
        const findIdx = res.findIndex((user) => user.password === user.password)
        if (!res || !res.length || findIdx === -1) {
          this.response = {
            Code: -1,
            Message: '用户未注册,登录失败',
            ReturnData: null
          }
          return this.response
        }
        if (res[findIdx].password !== user.password) {
          this.response = {
            Code: -1,
            Message: '密码错误,登录失败',
            ReturnData: null
          }
          return this.response
        }
        this.logger.info(null, `${user.username}登录成功`)
        const token = this.jwtService.sign({
          ...user,
          userId: res[0]._id,
          roles: res[0].roles,
          timestamp: Date.now()
        })
        this.response = {
          Code: 1,
          Message: '登录成功',
          ReturnData: {
            accessToken: token
          }
        }
        return this.response
      }
    
      /**
       * @description 获取用户的详细信息
       * @param user 用户的部分信息
       */
      async getUserInfo(user: UserSignUp) {
        const res = await this.findOneByName(user)
        if (!res || !res.length) {
          this.logger.error(null, `查询${user.username}的用户信息失败`)
          this.response = {
            Code: -1,
            Message: '未找到用户',
            ReturnData: null
          }
          return this.response
        }
        this.logger.info(null, `查询${user.username}的用户信息成功`)
        this.response = {
          Code: 1,
          Message: '成功',
          ReturnData: res[0]
        }
        return this.response
      }
    }

目前,我们已经实现了基本的日志模块注册登录以及查询用户信息接口,接下来,我们在项目中分别调用这三个接口,看看是否能打印出日志信息,步骤如下:

第一步:注册账号

    {
        username: 'Admin',
        password: '123456'
    }

第二步:登录账号,成功后跳转主页并查询用户信息

    {
        username: 'Admin',
        password: '123456'
    }

第三步:登录错误账号

    {
        username: 'Admin',
        password: '123456'
    }

image-20231125190053789.png

查看两个日志文件【info | error】中的最后几条记录,我们就可以看到刚才调用接口后打印的日志信息

image-20231125190648183.png

image-20231125190935422.png

// info
{"ctx":null,"level":"info","message":"新增用户成功","timestamp":"2023-11-25 18:59:46"}
{"ctx":null,"level":"info","message":"Admin登录成功","timestamp":"2023-11-25 19:06:14"}
{"ctx":null,"level":"info","message":"查询Admin的用户信息成功","timestamp":"2023-11-25 19:06:14"}
// error
{"ctx":null,"level":"error","message":"Admin123登录失败,未找到该用户","timestamp":"2023-11-25 19:09:18"}

以上是我们打印出来的 JSON 格式的日志信息,包括 ctx、level、message、timestamp 四个属性。在实际项目开发过程中,可以根据需要自定义合适的属性,在项目运行期间获取到对应的值并添加到日志中,也可以根据不同的行为自定义日志级别。

五、Conclusion

在项目开发过程中,日志的重要性不言而喻。开发人员可以在关键代码段或函数中添加日志语句,一方面,用于记录系统的安全相关活动,例如用户登录、权限变更、数据访问等,通过审计日志,可以检测到任何可疑的操作行为,并及时采取相应的安全措施;另一方面,当某段代码在运行期间出现问题或错误时,日志可以提供详细的错误信息和发生错误的时间,让开发人员和系统管理员可以通过查看日志来定位并解决问题。

但是呢,适合的才是最好的。无论是日志工具还是日志格式的选择,其实并没有固定的标准,在项目中集成日志工具时,还是要根据个人或公司的实际情况定义一套自己的日志系统,并在项目运行期间不断完善。