nestjs学习16:日志打印

0 阅读4分钟

前面都是用 console.log 打印的日志,这样有不少弊端:没有日志的不同级别的区分,不能通过开关控制是否打印等。其实 Nest 提供了打印日志的 api,学习下。

nestjs 中自带的日志工具 Logger

我们在 AppController 里创建个 logger 对象,使用它的 api 打印日志:

import { ConsoleLogger, Controller, Get, Logger } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  private logger = new Logger();

  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    this.logger.debug('aaa', AppController.name);
    this.logger.error('bbb', AppController.name);
    this.logger.log('ccc', AppController.name);
    this.logger.verbose('ddd', AppController.name);
    this.logger.warn('eee', AppController.name);
    
    return this.appService.getHello();
  }
}

有 5 种级别的日志:

error: 0
warn: 1
log: 2  
verbose: 3
debugger: 4

很多日志工具中 log 也叫 info,两者是一样的。

从上往下,重要程度依次降低。

比如当你指定 level 是 log 时,那 log、warn、error 的日志会输出,而 verbose、debug 这些不会。

日志级别的功能虽然简单,但却是很实用的功能。

image.png

这里的 verbose、debug、log、warn、error 就是日志级别,而 [] 中的是 context,也就是当前所在的上下文,最后是日志的内容。

这个日志是受 Nest 控制的,可以在创建应用的时候指定是否开启:

image.png

设置 logger 为 false 之后就没有日志了。

你也可以自己决定输出什么级别的日志:

image.png

此外,你还可以自定义日志打印的方式,定义一个实现 LoggerService 接口的类:

import { LoggerService, LogLevel } from '@nestjs/common';

export class MyLogger implements LoggerService {
    log(message: string, context: string) {
        console.log(`---log---[${context}]---`, message)
    }

    error(message: string, context: string) {
        console.log(`---error---[${context}]---`, message)
    }

    warn(message: string, context: string) {
        console.log(`---warn---[${context}]---`, message)
    }
}

在创建应用时指定这个 logger:

image.png

然后现在项目启动就是这样了:

image.png

你也可以不自己实现 LoggerService 的全部方法,而是继承 ConsoleLogger,重写一些方法:

import { ConsoleLogger } from '@nestjs/common';

export class MyLogger2 extends ConsoleLogger{
    log(message: string, context: string) {
        console.log(`[${context}]`,message)
    }
}

因为 ConsoleLogger 实现了 LoggerService 接口:

image.png

这样你没重写的方法就是原来的:

image.png

也就是说,要么你直接写一个类实现LoggerService,这是一个完全是新的logger;还有一种是你继承ConsoleLogger,改写其中的方法。

这就是创建应用时 logger 的 3 种取值:

image.png

但这样有个问题,没法注入依赖,因为 Logger 是在容器外面,手动 new 的对象。

怎么办呢?

这时候可以这样:

image.png

bufferLogs 就是先不打印日志,把它放到 buffer 缓冲区,直到用 useLogger 指定了 Logger 并且应用初始化完毕。

app.get 就是从容器中取这个类的实例的,我们写一个 Logger 类放到容器里:

import { Inject } from '@nestjs/common';
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { AppService } from './app.service';

@Injectable()
export class MyLogger3 extends ConsoleLogger{
    @Inject(AppService)
    private appService: AppService;

    log(message, context) {
        console.log(this.appService.getHello());
        console.log(`[${context}]`, message);
        console.log('--------------')
    }
}

添加 @Injectable() 装饰器,代表这是一个 provider,并且要在 Module 里引入:

image.png

现在的日志是这样的:

image.png

很明显,logger 里成功注入了 appService 的依赖。

winston

NestJS 的 ConsoleLogger 是框架内置的日志实现,继承自 LoggerService,其默认逻辑就是将日志输出到终端 / 控制台,但是打印完就没了,而服务端的日志经常要用来排查问题,需要搜索、分析日志内容,所以需要写入文件或者数据库里。

所以我们一般都会用专门的日志框架来做,比如 winston。

那 winston 都有什么功能?怎么用呢?

import winston from 'winston';

const logger = winston.createLogger({
    level: 'debug',
    format: winston.format.simple(),
    transports: [
        new winston.transports.Console(),
        new winston.transports.File({ 
            dirname: 'log', filename: 'test.log' 
        }),
    ]
});

logger.info('光光光光光光光光光');
logger.error('东东东东东东东东');
logger.debug(66666666);

用 createLogger 创建了 logger 实例,指定 level、format、tranports。

level:打印的日志级别

format:日志格式

transports:日志的传输方式

我们指定了 Console 和 File 两种传输方式。

image.png

可以看到控制台和文件里都有了打印的日志。

那么问题来了,如果所有日志都写在一个文件里,那这个文件最终会不会特别大?

不用担心,winston 支持按照大小自动分割文件:

image.png

我们指定 maxsize 为 1024 字节,也就是 1kb。

当超过 1kb 的时候,就能自动分割日志文件。

有同学说,一般日志都是按照日期自动分割的,比如 2023-10-28 的日志文件,2023-10-29 的日志文件,这样之后也好管理。

当然支持,但是要换别的 Transport 了。

在 winston 文档里可以看到有很多 Transport:

image.png

Console、File、Http、Stream 这几个 Transport 是内置的。

下面还有很多社区的 Transport,比如 MongoDB 的 Transport,很明显就是把日志写入 mongodb 的。

这里的 DailyRotateFile 就是按照日期滚动存储到日志文件的 Transport。

我们试试看:

npm install --save winston-daily-rotate-file

import winston from 'winston';
import 'winston-daily-rotate-file';

const logger = winston.createLogger({
    level: 'debug',
    format: winston.format.simple(),
    transports: [
        new winston.transports.Console(),
        new winston.transports.DailyRotateFile({
            level: 'info',
            dirname: 'log2',
            filename: 'test-%DATE%.log',
            datePattern: 'YYYY-MM-DD-HH-mm',
            maxSize: '1k'
        })
    ]
});

这里使用了 DailyRotateFile 的 transport,然后指定了文件名和日期格式。

指定文件名里的日志格式包含分钟,所以不同的分钟打印的日志会写入不同文件里。

image.png

这就达到了滚动日志的效果。

再来试试 http 的 transport:

const logger = winston.createLogger({
    level: 'debug',
    format: winston.format.simple(),
    transports: [
        new winston.transports.Console(),
        new winston.transports.Http({
            host: 'localhost',
            port: '3000',
            path: '/log'
        })
    ]
});

起一个http://localhost:3000/log的路由来接收。

基本上,内置的和社区的 transport 就足够用了,不管是想把日志发送到别的服务,还是把日志存到数据库等,都可以用不同 Transport 实现。

nestjs 集成 winston

那如何在 Nest 里集成 Winston 呢?

我们先看下如何自定义Logger。

在 src 添加一个 MyLogger.ts

import { LoggerService, LogLevel } from '@nestjs/common';

export class MyLogger implements LoggerService {
    log(message: string, context: string) {
        console.log(`---log---[${context}]---`, message)
    }

    error(message: string, context: string) {
        console.log(`---error---[${context}]---`, message)
    }

    warn(message: string, context: string) {
        console.log(`---warn---[${context}]---`, message)
    }
}

然后在 main.ts 里引入:

image.png

现在的 logger 就换成我们自己的了。

然后在 AppController 里添加 logger:

image.png

image.png

这样就完成了 logger 的自定义。

接下来只要换成 winston 的 logger 就好了。

npm install --save winston

然后改下 MyLogger:

import { ConsoleLogger, LoggerService, LogLevel } from '@nestjs/common';
import { createLogger, format, Logger, transports } from 'winston';

export class MyLogger implements LoggerService {

    private logger: Logger;

    constructor() {    
        this.logger = createLogger({
            level: 'debug',
            format: format.combine(
                format.colorize(),
                format.simple()
            ),
            transports: [
                new transports.Console()
            ]
        });
    }

    log(message: string, context: string) {
        this.logger.log('info', `[${context}] ${message}`);
    }

    error(message: string, context: string) {
        this.logger.log('error', `[${context}] ${message}`);
    }

    warn(message: string, context: string) {
        this.logger.log('warn', `[${context}] ${message}`);
    }
}

把 console.log 换成 winston 的 logger, 现在的日志就是 winston 的了。

image.png

只不过和 nest 原本的日志格式不大一样。

这个简单,我们自己写一下这种格式就好了。

安装 dayjs 格式化日期,安装 chalk 来打印颜色:

npm install --save dayjs
npm install --save chalk@4
import { ConsoleLogger, LoggerService, LogLevel } from '@nestjs/common';
import * as chalk from 'chalk';
import * as dayjs from 'dayjs';
import { createLogger, format, Logger, transports } from 'winston';

export class MyLogger implements LoggerService {

    private logger: Logger;

    constructor() {
        super();
    
        this.logger = createLogger({
            level: 'debug',
            transports: [
                new transports.Console({
                    format: format.combine(
                        format.colorize(),
                        format.printf(({context, level, message, time}) => {
                            const appStr = chalk.green(`[NEST]`);
                            const contextStr = chalk.yellow(`[${context}]`);
        
                            return `${appStr} ${time} ${level} ${contextStr} ${message} `;
                        })
                    ),
                })
            ]
        });
    }

    log(message: string, context: string) {
        const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');

        this.logger.log('info', message, { context, time });
    }

    error(message: string, context: string) {
        const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');

        this.logger.log('info', message, { context, time });
    }

    warn(message: string, context: string) {
        const time = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss');

        this.logger.log('info', message, { context, time });
    }
}

这里用到了format的 printf 函数,它可以自定义打印的日志格式。

我们用 chalk 加上了颜色,并且打印了 dayjs 格式化的时间。

效果是这样的:

image.png

是不是和 nest 原本的日志很像了?

然后我们再加一个 File 的 transport。

new transports.File({
    format: format.combine(
        format.timestamp(),
        format.json()
    ),
    filename: '111.log',
    dirname: 'log'
})

这样,我们就完成了 nest 和 winston 的集成。

我们还可以进一步把它封装成一个动态模块。

import { DynamicModule, Global, Module } from '@nestjs/common';
import { LoggerOptions, createLogger } from 'winston';
import { MyLogger } from './MyLogger';

export const WINSTON_LOGGER_TOKEN = 'WINSTON_LOGGER';

// 全局模块
@Global()
@Module({})
export class WinstonModule {

    public static forRoot(options: LoggerOptions): DynamicModule {    
        return {
            module: WinstonModule,
            providers: [
                {
                    provide: WINSTON_LOGGER_TOKEN,
                    useValue: new MyLogger(options)
                }
            ],
            exports: [
                WINSTON_LOGGER_TOKEN
            ]
        };
      }
}

添加 forRoot 方法,接收 winston 的 createLogger 方法的参数,返回动态模块的 providers、exports。

用 useValue 创建 logger 对象作为 provider。

这里要注意,useValue的值是一个new MyLogger对象,这样当其他模块@Inject注入的时候,就始终使用的这一个对象实例,保持了单例,这也就是单实例的原理。

这里的 MyLogger 是之前那个复制过来的,但需要改一下 constructor:

image.png

然后在 AppModule 引入下:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WinstonModule } from './winston/winston.module';
import { transports, format } from 'winston';
import * as chalk from 'chalk';

@Module({
  imports: [WinstonModule.forRoot({
      level: 'debug',
      transports: [
          new transports.Console({
              format: format.combine(
                  format.colorize(),
                  format.printf(({context, level, message, time}) => {
                      const appStr = chalk.green(`[NEST]`);
                      const contextStr = chalk.yellow(`[${context}]`);
  
                      return `${appStr} ${time} ${level} ${contextStr} ${message} `;
                  })
              ),

          }),
          new transports.File({
              format: format.combine(
                  format.timestamp(),
                  format.json()
              ),
              filename: '111.log',
              dirname: 'log'
          })
      ]
  })],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

之后改一下 main.ts 里用的 logger:

image.png

功能正常。

只不过现在就没必要每次都 new 了:

image.png

改成 inject 的方式,始终使用同一个实例,性能更好。

当然,其实这个模块没必要自己封装,社区有已经封装好的可以直接用 nest-winston 。

但是,通过封装一个 LoggerModule 可以了解如何进行功能模块的封装。