Skip to content

日志处理

作为开发人员,在开发环境中调试和定位问题相对容易。然而,在生成环境中,通常无法方便德附加调试器来追踪BUG。因此,日志记录的作用就显得非常重要了。

因此,建立一个全面有效的日志记录系统,对于任何应用程序的生产运行都是必不可少的。它不仅帮助我们监控应用程序的状态,还能在出现问题时提供关键的信息,帮助我们快速响应并恢复服务。

内置日志器Logger#

Nest中其实默认开启了日志记录(Logger),当我们运行pnpm run start:dev命令启动Nest应用的时候,都能看到启动过程中打印的信息,这其实就是Logger帮我们打印的。

我们先创建一个项目,直接启动就能看到相关的信息

nest n nest-logger -g -p pnpm

image-20250213144100760

从上图可以看到,日志信息包括时间戳、日志级别和上下文等等重要内容,根据这些信息,可以确认程序运行的状态。

默认的格式如下:

[AppName] [PID] [TimeStamp] [LogLevel] [Context] Message [+ms]

其中AppName为应用程序名称,一般固定为NestPID为系统分配的进程编号;TimeStamp为输出的当前系统时间。

Logger分为多种级别,包括logerrorwarndebugverbosefatal。我们可以指定任意组合启动记录,例如:只在出现错误或者警告的时候打印日志。

async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn'],
})
await app.listen(process.env.PORT ?? 3000)
}
bootstrap()

Context上下文表示当前日志产生的阶段,比如应用程序启动阶段或者路由程序执行阶段。

在一些情况下,我们可以使用Logger手动记录自定义事件或者消息,其实,就是将我们之前的console.log的打印,换成Logger对象就行,这样打印的信息更加全面,比如在AppService中开启Logger

@Injectable()
export class AppService {
private logger = new Logger(AppService.name)
getHello(): string {
this.logger.error('getHello error!')
return 'Hello World!'
}
}

当Controller调用getHello的时候,就会帮我打印这个error信息

image-20250213145139058

当然,可以很明显的看出,在Service上直接实例化了Logger对象,这当然不是太好的方式,更好的方式我们当然应该进行依赖注入。

我们可以自己创建一个Logger模块

nest g mo logger --no-spec

然后,在该模块下创建自己的logger类MyLogger,这个类需要至少继承Nest给我们提供父类ConsoleLogger或者实现更加底层的接口LoggerService

import { ConsoleLogger, Injectable } from '@nestjs/common'
@Injectable()
export class MyLogger extends ConsoleLogger {
error(message: any, trace?: string, context?: string) {
message = message + ' - 当前环境: dev'
super.error(message, trace, context)
}
log(message: any, context?: string) {
message = message + ' - 当前环境: dev'
super.log(message, context)
}
}

MyLogger类通过@Injectable()装饰之后,我们就可以实现注入,当然,现在由于我们是一个单独的模块,所以在logger.module.ts文件中,我们需要声明providers,而且我们肯定在外部需要用到MyLogger,所以记得需要exports

import { Module } from '@nestjs/common'
import { MyLogger } from './MyLogger'
@Module({
providers: [MyLogger],
exports: [MyLogger],
})
export class LoggerModule {}

此时,在main.ts中,通过useLogger方法将应用程序的默认日志器替换为我们自己定义的日志器实例。

async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
})
app.useLogger(app.get(MyLogger))
await app.listen(process.env.PORT ?? 3000)
}
bootstrap()

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

由于之前通过命令创建的logger模块,所以自动的引入到了AppModule中,现在,我们就可以直接在AppService中进行注入了

@Injectable()
export class AppService {
@Inject(MyLogger)
private logger: MyLogger
getHello(): string {
this.logger.error('getHello error!')
return 'Hello World!'
}
}

记录日志的正确姿势#

日志记录需要注意下面几点:

1、日志行为不应该出现异常。当我们使用日志记录系统行为时,首先需要保证日志行为不能出现异常,比如最常见的空指针异常等等,比如你像下面这样去写:

this.logger.log(`id=${getUser().id}`)

这句代码中,很有可能getUser()返回的结果为null,这就会导致获取id操作抛出异常,我们应该避免出现这种情况。

2、日志行为不应该产生副作用。日志应该是无状态的,日志行为不应该对业务逻辑差生任何副作用,否则系统行为可能会产生意外的结果,比如下面的伪代码:

this.logger.log(`用户保存成功:${userRepository.save(user)}`)

应该避免在日志记录中执行异步操作,以及会对系统结果差生影响的操作。

3、日志信息不应该包含敏感信息。日志只用于记录程序的执行状态,例如调用了哪些函数,出现了什么错误等等,不应该打印具体的信息,特别是敏感的信息,比如用户的账户信息等等。

4、日志记录应该尽可能详细。日志是面向开发人员的,当描述错误时,应该体积具体操作及失败原因,最好提示接下来应该怎样去做,比如:

this.logger.error(`用户${id}调用getUserInfo()方法失败,进行重试中...`, error)
getUserInfo(id)

第三方日志器Winston#

Nest内置的日志器可以在开发过程中记录系统行为,而在生成环境中通常使用专用的日志统计模块,比如**Winston**。

它能够满足特定的日志记录要求,包括高级过滤、格式化,和几种日志记录。

我们之前的代码结构几乎不需要做变动,只是在实现上和之前不同。当然,这之前我们还需要引入相应的包依赖:

Terminal window
pnpm add winston winston-daily-rotate-file dayjs -S
pnpm add chalk@4 -S

其中,

winston当然就是需要的日志框架,

winston-daily-rotate-file可以根据日期和大小限制进行日志文件的轮转,旧日志可以根据计数和已用天数进行删除。

dayjs用于格式化日期

chalk为控制台文本着色,**注意:**使用4的版本

image-20250213165918615

MyLogger.ts

import { Injectable, LoggerService } from '@nestjs/common'
import { createLogger, transports, Logger as WinstonLogger } from 'winston'
@Injectable()
export class MyLogger implements LoggerService {
private logger: WinstonLogger
constructor() {
this.logger = createLogger({
level: 'debug',
transports: [new transports.Console()],
})
}
log(message: string, context: string) {
this.logger.log('info', message, { context })
}
info(message: string, context: string) {
this.logger.info(message, { context })
}
error(message: string, context: string) {
this.logger.error(message, { context })
}
warn(message: string, context: string) {
this.logger.warn(message, { context })
}
debug(message: string, context: string) {
this.logger.debug(message, { context })
}
}

winston支持日志级别和npm的日志级别一致,优先级从 0 到 6(从高到低):

const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
silly: 6,
}

我们上面代码指定为debug,意味着小于数字5的日志级别都会被统计并记录

然后我们还需要在main.ts中指定使用Winston日志器,并关闭内置日志器

async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: false,
})
// 使用自定义Logger
app.useLogger(app.get(MyLogger))
await app.listen(process.env.PORT ?? 3000)
}
bootstrap()

现在我们在AppService中使用:

@Injectable()
export class AppService {
@Inject(MyLogger)
private logger: MyLogger
getHello(): string {
this.logger.info('getHello info!', AppService.name)
this.logger.warn('getHello warn!', AppService.name)
this.logger.error('getHello error!', AppService.name)
return 'Hello World!'
}
}

在运行pnpm run start:dev命令之后,就可以在控制台看到输出以下信息:

image-20250213161818359

当访问getHello路由,打印如下:

image-20250213161905032

当然了,现在日志还很难看,也差了一些东西,我们可以自己进行处理:

修改一下MyLogger.ts文件

import { Injectable, LoggerService } from '@nestjs/common'
import 'winston-daily-rotate-file'
import * as chalk from 'chalk'
import * as dayjs from 'dayjs'
import {
createLogger,
format,
transports,
Logger as WinstonLogger,
} from 'winston'
@Injectable()
export class MyLogger implements LoggerService {
private logger: WinstonLogger
constructor() {
this.logger = createLogger({
level: 'debug',
transports: [
new transports.Console({
format: format.combine(
// 颜色
format.colorize(),
// 日志格式
format.printf(({ context, level, message, timestamp }) => {
const appStr = chalk.blue('[Nest] ')
const contextStr = chalk.yellow(`[${context}]`)
return `${appStr} ${timestamp} ${level} ${contextStr}: ${message}`
})
),
}),
// 保存到文件
new transports.DailyRotateFile({
// 日志文件夹
dirname: process.cwd() + '/src/logs',
// 日志文件名 %DATE% 会自动替换为当前日期
filename: 'app-%DATE%.info.log',
// 日期格式
datePattern: 'YYYY-MM-DD',
// 压缩文档
zippedArchive: true,
// 文件最大大小,可以是Bytes、KB、MB、GB
maxSize: '20M',
// 最大文件数 ‘7d’ 表示7天
maxFiles: '7d',
// 日志格式
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.json()
),
// 日志等级,如果不设置,所有日志将会记录在同一文件中
level: 'info',
}),
new transports.DailyRotateFile({
dirname: process.cwd() + '/src/logs',
filename: 'app-%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',
}),
],
})
}
log(message: string, context: string) {
const timestamp = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss')
this.logger.log('info', message, { context, timestamp })
}
info(message: string, context: string) {
const timestamp = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss')
this.logger.info(message, { context, timestamp })
}
error(message: string, context: string) {
const timestamp = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss')
this.logger.error(message, { context, timestamp })
}
warn(message: string, context: string) {
const timestamp = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss')
this.logger.warn(message, { context, timestamp })
}
debug(message: string, context: string) {
const timestamp = dayjs(Date.now()).format('YYYY-MM-DD HH:mm:ss')
this.logger.debug(message, { context, timestamp })
}
}

现在启动程序,就能有比较好看的颜色和格式了:

image-20250213170116566

同时,当我们访问路由之后:

image-20250213170249894

至此,我们已经完成日志本地持久化的功能。不过在实际业务中,我们也可以将日志文件上传到远程服务器,或者通过制定transports.Http的方式将日志持久化到数据库中,以便进行后续的业务数据分析、数据过滤和数据清洗等操作。