Nestjs之23 集成winston日志

177 阅读3分钟

前言

日志对于排查问题有着非常重要的意义,本文将全面介绍如何在 NestJS 框架中集成 Winston 日志,打造一个功能完备、生产可用的日志系统。

包含以下内容:

  1. 请求和请求异常日志
  2. ORM日志
  3. redis日志
  4. 业务逻辑错误日志

1. 为什么选择 Winston?

Winston 作为 Node.js 生态中最成熟的日志库之一,具有以下核心优势:

  • ​多传输器支持​​:可同时输出到控制台、文件、远程服务等多种渠道
  • ​灵活的日志格式​​:支持 JSON、文本等多种格式,可自定义字段
  • ​智能日志轮转​​:按时间或大小自动分割日志文件
  • ​多级别日志管理​​:从 debug 到 error 的完整日志级别体系
  • ​高性能设计​​:异步日志记录不影响主线程性能

2. 日志服务基础搭建

  1. 创建日志模块
nest g res common/logger --no-specv

使用 Nest CLI 快速生成日志模块,--no-specv 参数表示不生成测试文件。

2.基础日志服务实现

import { Injectable, Logger, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {
  private context?: string;
  constructor(private readonly logger: Logger) { }

  public setContext(context: string) {
    this.context = context;
  }

  log(message: any, context?: string) {
    return this.logger.log(message, context || this.context);
  }

  error(message: any, trace?: string, context?: string) {
    return this.logger.error(message, trace, context || this.context);
  }

  warn(message: any, context?: string) {
    return this.logger.warn(message, context || this.context);
  }
}
  1. 使用 @Injectable({ scope: Scope.TRANSIENT }) 使每个注入点获得独立实例
  2. 提供 setContext 方法设置日志上下文
  3. 封装了 logerrorwarn 等基础日志方法
  4. 支持覆盖上下文或使用预设上下文
import { LoggerModule } from './logger/logger.module';
import { Logger } from '@nestjs/common';

@Global()
@Module({
  imports: [LoggerModule],
  providers: [Logger],
  exports: [Logger, LoggerModule],
})
  1. 使 Logger 服务在整个应用中可用
  2. 同时导出了内置 Logger 和自定义 LoggerModule
  3. 任何模块只需导入 CommonModule 即可使用日志功能
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
  logger: setupLogger()
});

通用的日志模块创建完成。

3. 接入Winston

  1. 安装依赖
pnpm i nest-winston winston winston-daily-rotate-file

安装三个核心包:

  • winston: 主流 Node.js 日志库
  • nest-winston: NestJS 集成包装
  • winston-daily-rotate-file: 日志文件轮转插件
  1. 日志配置
import * as winston from 'winston';
import { loadEnvConfig } from '../config/loadEnv.config';
import { EnvEnum } from '../enums/env.enum';
import { utilities } from 'nest-winston';
import { Console } from 'winston/lib/winston/transports';
import 'winston-daily-rotate-file';

function createDailyRotateTransport(level: string, filename: string) {
  return new winston.transports.DailyRotateFile({
    level,
    dirname: 'logs', //日志文件夹
    filename: `${filename}-%DATE%.log`, //日志名称,占位符 %DATE% 取值为 datePattern 值
    datePattern: 'YYYY-MM-DD', //日志轮换的频率,此处表示每天。其他值还有:YYYY-MM、YYYY-MM-DD-HH、YYYY-MM-DD-HH-mm
    zippedArchive: true, //是否通过压缩的方式归档被轮换的日志文件
    maxSize: '20m', // 设置日志文件的最大大小,m 表示 mb 。
    maxFiles: '14d', // 保留日志文件的最大天数,此处表示自动删除超过 14 天的日志文件
    format: winston.format.combine(
      winston.format.timestamp({
        format: 'YYYY-MM-DD HH:mm:ss',
      }),
      winston.format.simple(),
    ),
  });
}

export const setupLogger = () => {
  const config = loadEnvConfig();
  const timestamp = config[EnvEnum.TIMESTAMP] === 'true';
  const combine = [];
  if (timestamp) {
    combine.push(winston.format.timestamp());
  }
  combine.push(utilities.format.nestLike());
  const consoleTransports = new Console({
    level: (config[EnvEnum.LOG_LEVEL] as string) || 'info',
    format: winston.format.combine(...combine),
  });
  const otherTransports =
    config[EnvEnum.LOG_ON] === 'true'
      ? [
        createDailyRotateTransport('info', 'application'),
        createDailyRotateTransport('warn', 'error'),
      ]
      : [];
// 此处使用 nest-winston  WinstonModule
  return winston.createLogger({
    transports: [consoleTransports, ...otherTransports],
  });
};

这是使用 Winston 日志库的日志配置文件,主要实现了以下功能:

  1. createDailyRotateTransport 函数:创建按日期轮转的日志传输器
  • 主要特点:

    • 日志文件按天分割存储

    • 自动压缩归档旧日志

    • 限制单个日志文件大小为 20MB

    • 保留最近 14 天的日志

    • 日志格式包含时间戳,格式为 "YYYY-MM-DD HH:mm:ss"

  1. setupLogger 函数:配置并创建 Winston 日志实例
  • 包含两种日志输出方式:控制台输出:根据环境配置决定日志级别和是否显示时间戳

    • 文件输出:当 LOG_ON=true 时启用

      • application-日期.log:记录普通信息日志

      • error-日期.log:记录警告和错误日志

主要配置项来自环境变量:

  • TIMESTAMP:是否在日志中显示时间戳
  • LOG_LEVEL:日志级别(默认为 'info')
  • LOG_ON:是否启用文件日志记录

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

  • level:打印的日志级别
  • format:日志格式
  • transports:日志的传输方式 指定了 Console 和 File 两种传输方式。

注意:没有使用nest-winston日志没有颜色

image.png

使用后

import { utilities, WinstonModule } from 'nest-winston';

此处使用 nest-winston  WinstonModule
  return WinstonModule.createLogger({
    transports: [consoleTransports, ...otherTransports],
  });

image.png

4. 请求异常过滤器添加日志

我们需要在请求异常的日志中添加日志监听,如下代码:

import { LoggerService } from '../logger/logger.service';
import { getClientIp } from '@/utils/request.info';

constructor(private readonly logger: LoggerService) { }

 this.logger.error({
    ...responseBody,
    ip: getClientIp(request)
  }, exception?.stack || null, 'HttpException')
  1. 记录完整的响应体
  2. 附加客户端 IP 地址
  3. 包含错误堆栈信息
  4. 明确标记为 'HttpException' 类型

image.png

image.png

5. 请求日志

为每一个请求添加日志,如下代码:

import { LoggerService } from '../logger/logger.service';
constructor(private readonly logger: LoggerService) { }

this.logger.setContext(context.getClass().name);
this.logger.log(JSON.stringify(result));

image.png

image.png

6. 添加日志中间件

nest g mi common

import { Injectable, NestMiddleware } from '@nestjs/common';
import { LoggerService } from '../logger/logger.service';
import { Request, Response } from 'express';
import { getReqMainInfo } from '@/utils/request.info';

@Injectable()
export class LogMiddleware implements NestMiddleware {
  constructor(private readonly logger: LoggerService) { }
  use(req: Request, res: Response, next: () => void) {
    // 记录日志
    this.logger.log(getReqMainInfo(req), req.url)
    next();
  }
}

配置请求日志中间件


export class CommonModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LogMiddleware).forRoutes(
      { path: '*', method: RequestMethod.ALL },
    )
  }
}
  1. 拦截所有请求
  2. 记录请求主要信息(方法、URL、headers等)
  3. 使用请求 URL 作为日志上下文
  4. 通过 getReqMainInfo 提取关键请求数据

image.png

image.png

7. prisma添加日志

import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma, PrismaClient } from '@prisma/client';
import { LoggerService } from '../logger/logger.service';
import { EnvEnum } from '../enums/env.enum';

type EventType = "query" | "info" | "warn" | "error";


@Injectable()
export class PrismaService extends PrismaClient<Prisma.PrismaClientOptions, EventType> implements OnModuleInit {
  constructor(
    private readonly configService: ConfigService,
    private readonly logger: LoggerService
  ) {
    super({
      datasourceUrl: configService.get<string>(EnvEnum.DATABASE_URL), // 有问题没有效果
      errorFormat: 'pretty',
      log: [
        { emit: "event", level: "query" },
        { emit: "event", level: "info" },
        { emit: "event", level: "warn" },
        { emit: "event", level: "error" },
      ],
    });
    this.logger.setContext(PrismaService.name)
  }

  async onModuleInit() {
    try {
      await this.$connect();
      this.logger.log(`prisma connect success ✅`);
      this.log()
    } catch (error) {
      this.logger.error(`prisma connect error ❌`, error)
    }
  }

  async onModuleDestroy() {
    try {
      await this.$disconnect();
      this.logger.log(`prisma disconnect success ✅`);
      this.log()
    } catch (error) {
      this.logger.error(`prisma disconnect error ❌`, error)
    }
  }

  private log() {
    this.$on("query", (e) => {
      this.logger.log(
        `sql: 📝 ${e.query} - params: 💬 ${e.params} - duration: 🚀 ${e.duration}ms`,
      );
    });
    this.$on("error", (e) => {
      this.logger.error(
        `🔖 errorMessage: ${e.message} - target: ${e.target} - timestamp: ${e.timestamp}`,
      );
    });
    this.$on("warn", (e) => {
      this.logger.warn(
        `warnMessage: ${e.message} - target: ${e.target} - timestamp: ${e.timestamp}`,
      );
    });
    this.$on("info", (e) => {
      this.logger.log(
        `infoMessage: ${e.message} - target: ${e.target} - timestamp: ${e.timestamp}`,
      );
    });
  }
}
  1. 监听 SQL 查询事件
  2. 记录查询语句和参数
  3. 显示查询执行时间

image.png

8. redis添加日志

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Redis } from 'ioredis';
import { EnvEnum } from '../enums/env.enum';
import { LoggerService } from '../logger/logger.service';

@Injectable()
export class RedisService
  extends Redis
  implements OnModuleInit, OnModuleDestroy {
  constructor(
    private readonly configService: ConfigService,
    private readonly logger: LoggerService,
  ) {
    super({
      host: configService.get(EnvEnum.REDIS_HOST),
      port: configService.get(EnvEnum.REDIS_PORT),
      password: configService.get(EnvEnum.REDIS_PASSWORD),
      lazyConnect: true, // 延迟连接
    });
    this.logger.setContext(RedisService.name)
  }

  async _get(key: string) {
    return JSON.parse(await this.get(key));
  }

  async _setex(key: string, seconds: number, value: any) { }

  async _set(key: string, value: any) {
    await this.set(key, JSON.stringify(value));
  }

  async _delKeysWithPrefix(prefix: string) {
    const keys = await this.keys(`${prefix}*`);
    if (keys.length === 0) {
      return 0;
    }
    return await this.del(...keys);
  }

  async _delKeysContainStr(str: string) {
    const keys = await this.keys(`*${str}*`);
    if (keys.length === 0) {
      return 0;
    }
    return await this.del(...keys);
  }

  async onModuleInit() {
    try {
      await this.connect();
      //删除所有key
      this.flushall();
      this.logger.log("redis connect success ✅");
    } catch (error) {
      this.logger.error("redis connect error ❌", error)
    }
  }
  onModuleDestroy() {
    try {
      this.disconnect(false);
      this.logger.log("redis disconnect success ✅");
    } catch (error) {
      this.logger.error("redis disconnect error ❌", error)
    }
  }
}
  1. 记录连接成功/失败状态
  2. 错误时记录完整错误对象

image.png

添加事件

export function RecordTime() {
	return function (
		target: any,
		propertyKey: string,
		descriptor: PropertyDescriptor,
	) {
		const method = descriptor.value;
		descriptor.value = async function (...args: any[]) {
			const startTime = Date.now();
			const result = await method.apply(this, args);
			const endTime = Date.now();
			this.logger.log(
				`${propertyKey} was called , args: ${args} time: 🚀 ${
					endTime - startTime
				}ms`,
			);
			return result;
		};
	};
}
@RecordTime()
  async _get(key: string) {
    return JSON.parse(await this.get(key));
  }

  @RecordTime()
  async _setex(key: string, seconds: number, value: any) { }

  @RecordTime()
  async _set(key: string, value: any) {
    await this.set(key, JSON.stringify(value));
  }

  @RecordTime()
  async _delKeysWithPrefix(prefix: string) {
    const keys = await this.keys(`${prefix}*`);
    if (keys.length === 0) {
      return 0;
    }
    return await this.del(...keys);
  }

  @RecordTime()
  async _delKeysContainStr(str: string) {
    const keys = await this.keys(`*${str}*`);
    if (keys.length === 0) {
      return 0;
    }
    return await this.del(...keys);
  }

上面装饰器作用:

  1. 包装原始方法
  2. 记录方法执行前后时间戳
  3. 计算并输出执行耗时
  4. 保留原方法返回值不变

image.png

9. 业务错误统一处理

  1. 首先记录原始错误
  2. 识别 Prisma 特有错误代码
  3. 根据错误类型返回友好提示
  4. 最终抛出标准化异常
import { PrismaErrorCode } from "@/common/enums/prisma-error-code.enum";
import { LoggerService } from "@/common/logger/logger.service";
import { BadRequestException, HttpException } from "@nestjs/common";
import { Prisma } from "@prisma/client";

interface ErrorMessage {
  common?: string;
  unique?: string;
  foreign?: string;
}

export const handleError = (logger: LoggerService, error: any, messages: ErrorMessage) => {
  logger.error(error);
  if (
    error instanceof Prisma.PrismaClientKnownRequestError &&
    error.code == PrismaErrorCode.UniqueConstraintViolation &&
    messages.unique
  ) {
    throw new BadRequestException(`${messages.unique}`);
  } else if (
    error instanceof Prisma.PrismaClientKnownRequestError &&
    error.code == PrismaErrorCode.ForeignKeyConstraintViolation &&
    messages.foreign
  ) {
    throw new BadRequestException(`${messages.foreign}`);
  } else if (error.message && error instanceof HttpException) {
    throw new BadRequestException(error.message);
  }
  throw new BadRequestException(`${messages.common}`);

}

使用:

handleError(this.logger, 111, {
  common: '创建失败',
})

image.png

总结

最后总结一下: 创建基础日志模块了支持多级别日志记录和上下文分类的通用日志服务;通过Winston实现了日志文件自动分割、压缩和定期清理功能。

另外对请求使用中间件和异常过滤器记录请求信息和错误详情;为Prisma和Redis添加了SQL查询、操作耗时和连接状态监控;实现了业务错误的分类处理和友好提示返回。

如有错误,请指正O^O!