Nest.js使用nest-pino记录日志

  • 后端服务肯定少不了日志,没打日志出了问题就只能抓瞎。当然,如果你能找到神奇海螺问问,或者自己就会用水晶球占卜,不打日志还是可以的。

  • Node.js 流行的日志记录库,大概有 Winston(20.2K 星星)、Bunyan(7K 星星)、Pino.js(11.1K 星星)这几种,现在我一般都会优先考虑 Pino.js。原因很简单,馋它的高性能。具体性能对比可以参考 Pino.js 的 Benchmarks

  • Nest.js 底层依赖的 Web 框架我默认都是换成 Fastify 的,然后发现 Fastify 默认的日志记录器就是 Pino.js,就想着偷个懒,在初始化 FastfiyAdapter 的时候,传入参数配置,启用 Pino.js 然后将实例导出,供其他地方调用。

  • 大致逻辑如下:

    // fastify.adapter.ts
    import { FastifyAdapter } from '@nestjs/platform-fastify';
    import { FastifyBaseLogger } from 'fastify';
    
    const envLogger = {
      development: {
        transport: {
          target: 'pino-pretty',
          options: {
            translateTime: 'HH:MM:ss Z',
          },
        },
      },
      production: true,
    };
    
    const fastifyAdapter: FastifyAdapter = new FastifyAdapter({
      logger: envLogger[process.env.NODE_ENV] ?? true,
    });
    
    const fastify: FastifyAdapter = fastifyAdapter.getInstance();
    const logger = (fastify as any).log as FastifyBaseLogger;
    
    export { fastifyAdapter, fastify, logger };
    
    // 其他业务模块.ts
    import { logger } from '@/common/adapters/fastify.adapter.ts'
    // 直接输出日志
    logger.info("这是一条日志")
    // 或者是新实例化一个对象,保存当前调用上下文的名字,然后再使用,这样方便后期处理
    const userLogger = logger.child({ context: UserService.name });
    userLogger.info("用户鉴权失败");
    
  • 这样的确可以使用,但是是有一些潜在的缺陷的,最根本的原因在于 Fastify 和 Nest.js 在封装上并不是一个级别的东西。Fastify 只是 Nest.js 下层依赖的一个 Web 框架,如果我们想要在 Nest.js 的生命周期事件或者是应用程序级别上打日志的话,上面的那个封装就力不从心了。

  • ummm,看样子最后还是偷懒不成的 = =。

  • 好在,去 npm 上看了一下,发现有一个包叫做 nestjs-pino,看了一下 README 也比较符合需求,直接拿过来用就行了,然后将原本服务中的 logger 命令改一下,完事。

  • 下面说一下我使用 nest-pino 这个包的大致流程。

  • 首先,pnpm add nestjs-pino​​​​​​安装依赖,然后去 app.module.ts​​​​​​中引入,

    // app.module.ts
    import { LoggerModule } from 'nestjs-pino';
    import { loggerOptions } from './processors/logger/logger.config';
    
    @Module({
      imports: [
        // 这里导入并使用了一个自定义的配置项
        LoggerModule.forRoot({ pinoHttp: loggerOptions }),
      ],
      controllers: [AppController],
      providers: [AppService],
    })
    export class AppModule {}
    
  • 然后,在 logger.config.ts 中,写入自定义的配置

    import pino from 'pino';
    import { Options } from 'pino-http';
    
    /** 日志保存的路径 */
    const LOG_PATH = './logs/log.log';
    
    /** 开发环境下的配置选项 */
    const devOptions: Options = {
      /** 对每个请求/响应的自动日志设置上下文的值 */
      // customProps: (req, res) => ({ context: 'HTTP' }),
      transport: {
        target: 'pino-pretty',
        options: {
          translateTime: 'HH:MM:ss Z',
        },
      },
      // 输出到控制台,或者设置成如下,同时输出到控制台和文件中:
      // pino.multistream([pino.destination(LOG_PATH), process.stdout])
      stream: pino.destination(process.stdout),
    };
    /** 生产环境下的配置选项 */
    const proOptions: Options = {
      stream: pino.destination(LOG_PATH),
    };
    
    /** 根据环境切换日志配置 */
    const envLogger = {
      development: devOptions,
      production: proOptions,
    };
    
    /** Pino日志配置 */
    export const loggerOptions: Options = envLogger[process.env.NODE_ENV];
    
  • 上面的代码,主要是区分开发环境和生产环境,对日志做了不同的处理:在开发环境下,日志直接输出到控制台,生产环境下,日志输出到自定义的路径下。如果项目中有统一的配置管理,应该将这个日志路径的配置和其它的配置项放在一起。

  • 额外解释一下,nestjs-pino 是 基于 http-pino 进行封装的,它的一个特性就是可以自动将请求相关的数据绑定到日志上。但是默认绑定的日志是没有上下文的,所以为了方便后期进行分析或处理,可以使用 customProps: (req, res) => ({ context: 'HTTP' }),​​​ ​这个配置打上上下文的标记。

  • 💡 上面的配置项依赖于 pino、http-pino、pino-pretty,需要安装对应的依赖。
    nestjs-pino 依赖于 http-pino,而 http-pino 依赖于 pino,可以看作是一个整体的包。pino-pretty 只需要安装为开发依赖即可,使用它主要是为了在开发环境中将 JSON 格式化为人类更易读的格式。

  • 最后,我们在 main.ts 中引入

    import { AppModule } from './app.module';
    import { fastifyAdapter } from './common/adapters/fastify.adapter';
    import { Logger } from 'nestjs-pino';
    import { NestFactory } from '@nestjs/core';
    import { NestFastifyApplication } from '@nestjs/platform-fastify';
    async function bootstrap() {
      const app = await NestFactory.create<NestFastifyApplication>(
        AppModule,
        fastifyAdapter,
        { cors: true, bufferLogs: true },
      );
      app.useLogger(app.get(Logger));
      await app.listen(PORT, ADDRESS);
    }
    bootstrap();
    
  • 这样就可以在项目中使用了,还是默认的日志记录器的方法

    // 注意:这里是从@nestjs/common包里导入的Nest.js内置日志记录器
    // 但是它内部由Pino.js驱动
    
    import { Logger } from '@nestjs/common';
    export class UserService {
      // 这里传入当前服务的名字,是为了之后方便判断日志是由哪个业务模块打印的
      private readonly logger = new Logger(UserService.name);
      constructor() {}
    
      async getUserById(uId: number) {
        // 主要注意的一点是Pino.js的`trace`和`info`方法,映射为了`verbose`和`log`方法
        this.logger.error('巴拉巴拉巴拉');
      }
    }
    
  • 除了这种方式,还可以使用 Nest.js 常见的依赖注入的方式来使用,可参考 官方文档

    import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';
    
    export class MyService {
      // 例1
      constructor(private readonly logger: PinoLogger) {
        this.logger.setContext(MyService.name);
      }
      // 例2
      constructor(
        @InjectPinoLogger(MyService.name)
        private readonly logger: PinoLogger,
      ) {}
    
      foo() {
        this.logger.trace({ foo: 'bar' }, 'baz %s', 'qux');
        this.logger.debug('foo %s %o', 'bar', { baz: 'qux' });
        this.logger.info('foo');
      }
    }
    
  • 最后要说的一点就是使用日志对服务性能会有较大的影响,但是这一部分的开销是无法节省的,只能尽可能的优化。

  • Node.js 部署的项目,一般都会在前面套一个 Nginx 来配合使用,起到一个反向代理或负载均衡的作用。而 Nginx 本身也是带有 HTTP 请求的日志记录功能的,所以注意一下 Nginx 和项目的日志记录不要重复。

  • 可以使用 Nginx 来记录 HTTP 日志,而将 nestjs-pino 的请求记录关闭,在应用内手动在关键的地方打日志。

  • 如果觉得这样麻烦,也可以反过来,关闭 Nginx 的日志,所有的日志都由项目自身来处理。