前面都是用 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 这些不会。
日志级别的功能虽然简单,但却是很实用的功能。
这里的 verbose、debug、log、warn、error 就是日志级别,而 [] 中的是 context,也就是当前所在的上下文,最后是日志的内容。
这个日志是受 Nest 控制的,可以在创建应用的时候指定是否开启:
设置 logger 为 false 之后就没有日志了。
你也可以自己决定输出什么级别的日志:
此外,你还可以自定义日志打印的方式,定义一个实现 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:
然后现在项目启动就是这样了:
你也可以不自己实现 LoggerService 的全部方法,而是继承 ConsoleLogger,重写一些方法:
import { ConsoleLogger } from '@nestjs/common';
export class MyLogger2 extends ConsoleLogger{
log(message: string, context: string) {
console.log(`[${context}]`,message)
}
}
因为 ConsoleLogger 实现了 LoggerService 接口:
这样你没重写的方法就是原来的:
也就是说,要么你直接写一个类实现LoggerService,这是一个完全是新的logger;还有一种是你继承ConsoleLogger,改写其中的方法。
这就是创建应用时 logger 的 3 种取值:
但这样有个问题,没法注入依赖,因为 Logger 是在容器外面,手动 new 的对象。
怎么办呢?
这时候可以这样:
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 里引入:
现在的日志是这样的:
很明显,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 两种传输方式。
可以看到控制台和文件里都有了打印的日志。
那么问题来了,如果所有日志都写在一个文件里,那这个文件最终会不会特别大?
不用担心,winston 支持按照大小自动分割文件:
我们指定 maxsize 为 1024 字节,也就是 1kb。
当超过 1kb 的时候,就能自动分割日志文件。
有同学说,一般日志都是按照日期自动分割的,比如 2023-10-28 的日志文件,2023-10-29 的日志文件,这样之后也好管理。
当然支持,但是要换别的 Transport 了。
在 winston 文档里可以看到有很多 Transport:
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,然后指定了文件名和日期格式。
指定文件名里的日志格式包含分钟,所以不同的分钟打印的日志会写入不同文件里。
这就达到了滚动日志的效果。
再来试试 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 里引入:
现在的 logger 就换成我们自己的了。
然后在 AppController 里添加 logger:
这样就完成了 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 的了。
只不过和 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 格式化的时间。
效果是这样的:
是不是和 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:
然后在 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:
功能正常。
只不过现在就没必要每次都 new 了:
改成 inject 的方式,始终使用同一个实例,性能更好。
当然,其实这个模块没必要自己封装,社区有已经封装好的可以直接用 nest-winston 。
但是,通过封装一个 LoggerModule 可以了解如何进行功能模块的封装。