Skip to content

NestJS Winston 日志系统实现指南

本文档介绍在 NestJS 中集成 Winston 日志系统的完整实现方法,包括基础集成、访问日志记录、错误处理、日志组织等核心知识点。

功能概述

本指南实现一个完整的 NestJS 日志系统,包含以下功能:

  • 基础日志记录(终端输出 + 文件存储)
  • HTTP 请求日志记录(中间件实现)
  • 全局异常捕获与记录(过滤器实现)
  • 按环境区分日志格式
  • 规范的日志文件组织结构

技术栈

json
{
  "dependencies": {
    "nest-winston": "^1.10.2", // NestJS Winston 集成模块
    "winston": "^3.19.0", // Winston 日志核心库
    "winston-daily-rotate-file": "^5.0.0", // 日志文件按日期分割
    "@nestjs/config": "^4.0.3", // 配置管理
    "uuid": "9.0.0" // 生成唯一请求ID
  }
}

核心功能

1. 基础日志集成

1.1 替换 NestJS 默认日志器

main.ts 中全局替换为 Winston:

typescript
import { WinstonModule } from 'nest-winston';
import { createWinstonLogger } from './config/winston.config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    // 全局替换 nest 的日志记录器为 winston
    logger: WinstonModule.createLogger({
      instance: createWinstonLogger(),
    }),
  });
}

关键点

  • 必须在 bootstrap 中创建应用时配置
  • 这样可以捕获 NestJS 框架层面的日志(启动日志、模块加载等)

1.2 Winston 配置

在配置文件中创建两个 Logger 实例:

应用日志 Logger - 用于记录业务日志:

typescript
export const createWinstonLogger = (): winston.Logger => {
  return createLogger({
    transports: [
      // 终端打印
      new winston.transports.Console({
        level: 'debug',
        format: winston.format.combine(
          winston.format.timestamp({
            format: 'YYYY-MM-DD HH:mm:ss.SSS ZZ',
          }),
          nestWinstonModuleUtilities.format.nestLike(),
        ),
      }),

      // 主日志文件:记录应用日志(info、warn、error)
      new winston.transports.DailyRotateFile({
        level: 'info',
        dirname: 'logs/application',
        filename: 'application-%DATE%.log',
        datePattern: 'YYYY-MM-DD-HH',
        zippedArchive: true,
        maxSize: '20m',
        maxFiles: '30d',
        format: logFormat,
      }),

      // 错误日志文件:只记录错误日志
      new winston.transports.DailyRotateFile({
        level: 'error',
        dirname: 'logs/errors',
        filename: 'error-%DATE%.log',
        datePattern: 'YYYY-MM-DD-HH',
        zippedArchive: true,
        maxSize: '20m',
        maxFiles: '60d',
        format: logFormat,
      }),
    ],
  });
};

访问日志 Logger - 专门用于记录 HTTP 请求:

typescript
export const createAccessLogger = (): winston.Logger => {
  return createLogger({
    transports: [
      // 访问日志文件:只记录HTTP请求日志
      new winston.transports.DailyRotateFile({
        level: 'info',
        dirname: 'logs/access',
        filename: 'access-%DATE%.log',
        datePattern: 'YYYY-MM-DD-HH',
        zippedArchive: true,
        maxSize: '20m',
        maxFiles: '7d', // 访问日志保留7天
        format: getAccessLogFormat(),
      }),
    ],
  });
};

为什么需要两个 Logger?

  • 职责分离:应用日志和访问日志记录目的不同,分开管理更清晰
  • 存储策略不同:访问日志量大但价值较低(保留7天),错误日志价值高(保留60天)
  • 格式不同:访问日志需要特定的字段(请求ID、响应时间等),应用日志需要堆栈跟踪

2. 环境区分的日志格式

2.1 开发环境 - 文本格式

typescript
const createCustomLogFormat = () => {
  return winston.format.printf((info: any) => {
    const { timestamp, level, message, context, stack } = info;
    const contextStr = context ? `[${String(context)}] ` : '';
    const stackStr = stack ? `\n${String(stack)}` : '';
    return `[${timestamp}] [${String(level).toUpperCase()}] ${contextStr}${String(message)}${stackStr}`;
  });
};

输出示例

[2026-03-23 16:00:00.000 +0800] [INFO] [UsersController] users.controller.getUsers() called

2.2 生产环境 - JSON 格式

typescript
const createJsonLogFormat = () => {
  return winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json(),
  );
};

输出示例

json
{
  "timestamp": "2026-03-23T08:00:00.000Z",
  "level": "info",
  "message": "users.controller.getUsers() called",
  "context": "UsersController"
}

2.3 动态选择格式

typescript
const getLogFormat = () => {
  const isProduction = process.env.NODE_ENV === 'production';

  if (isProduction) {
    return createJsonLogFormat();
  } else {
    return winston.format.combine(
      winston.format.timestamp({
        format: 'YYYY-MM-DD HH:mm:ss.SSS ZZ',
      }),
      winston.format.errors({ stack: true }),
      createCustomLogFormat(),
    );
  }
};

为什么需要区分?

  • 开发环境:文本格式便于开发者直接阅读,提高开发效率
  • 生产环境:JSON 格式便于日志收集系统(如 ELK)解析和分析

3. 访问日志记录(中间件)

3.1 中间件实现

核心功能

  1. 生成请求 ID
typescript
import { v4 as uuidv4 } from 'uuid';

req.id = uuidv4().split('-')[0]; // 使用 UUID 前 8 位
req.startTime = Date.now();

// 添加请求 ID 到响应头(便于前端追踪)
res.setHeader('X-Request-ID', requestId);
  1. 记录请求开始
typescript
this.logger.info(`${method} ${originalUrl} - ${ip} - ${userAgent}`, {
  requestId,
  context: 'LoggingMiddleware',
});
  1. 监听响应结束
typescript
res.on('finish', () => {
  const responseTime = Date.now() - req.startTime;

  // 根据状态码选择日志级别
  const logMethod =
    statusCode >= 500
      ? 'error' // 5xx:服务器错误
      : statusCode >= 400
        ? 'warn' // 4xx:客户端错误
        : 'info'; // 2xx/3xx:正常

  this.logger[logMethod](
    `${method} ${originalUrl} - ${statusCode} - ${responseTime}ms - ${contentLength}bytes`,
    { requestId, statusCode, responseTime },
  );
});

3.2 注册中间件

typescript
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggingMiddleware).forRoutes('*'); // 拦截所有路由
  }
}

为什么在 finish 事件中记录?

  • 此时响应已完成,可以获取完整的响应信息(状态码、响应时间等)
  • finish 事件在响应发送给客户端后触发,不会影响响应性能

为什么要用独立的 Logger?

  • 访问日志数据量大,单独存储避免污染业务日志
  • 便于日志分析和监控系统单独处理访问日志
  • 可以设置不同的保留策略(如访问日志只保留 7 天)

4. 全局异常过滤器

4.1 过滤器实现

核心逻辑

  1. 捕获所有异常
typescript
@Catch()  // 不传参数则捕获所有异常
export class AllExceptionsFilter implements ExceptionFilter
  1. 区分异常类型
typescript
const isHttpException = exception instanceof HttpException;
const status = isHttpException
  ? exception.getStatus()
  : HttpStatus.INTERNAL_SERVER_ERROR;

// 提取错误消息
const exceptionResponse = isHttpException
  ? exception.getResponse()
  : { message: 'Internal server error' };
  1. 记录错误日志
typescript
this.logger.error(logMessage, {
  requestId, // 关联请求
  statusCode: status,
  method: request.method,
  url: request.originalUrl,
  ip: request.ip,
  stack, // 堆栈信息
  context: 'AllExceptionsFilter',
});
  1. 统一响应格式
typescript
const errorResponse = {
  statusCode: status,
  timestamp: new Date().toISOString(),
  path: request.url,
  method: request.method,
  message: errorMessage,
  // 只在开发环境返回堆栈信息,生产环境不返回,防止暴露服务器代码
  ...(process.env.NODE_ENV === 'development' && stack ? { stack } : {}),
};

4.2 注册过滤器

typescript
app.useGlobalFilters(new AllExceptionsFilter());

重要注意事项

  • 安全性:生产环境不返回堆栈信息,防止泄露代码结构
  • 完整性:捕获所有异常(不传参数给 @Catch()),包括未预期的异常
  • 可追踪性:记录 requestId,便于在访问日志中关联请求

5. 日志文件组织结构

5.1 目录结构

logs/
├── access/          # 访问日志(HTTP 请求)
│   └── access-2026-03-23-16.log
├── application/     # 应用日志(info、warn、error)
│   └── application-2026-03-23-16.log
└── errors/          # 错误日志(仅 error)
    └── error-2026-03-23-16.log

5.2 分层策略

目录日志级别保留时间用途
accessinfo7天访问日志,量大但价值低
applicationinfo/warn/error30天业务日志,常规排查
errorserror60天错误日志,长期追踪

为什么这样分层?

  1. 快速定位:出问题时直接查看 errors/ 目录
  2. 存储优化:不同价值的日志设置不同保留期限
  3. 分析便利:访问日志单独存储,便于流量分析

5.3 日志轮转配置

typescript
new winston.transports.DailyRotateFile({
  level: 'info',
  dirname: 'logs/application',
  filename: 'application-%DATE%.log',
  datePattern: 'YYYY-MM-DD-HH', // 按小时轮转
  zippedArchive: true, // 自动压缩旧日志
  maxSize: '20m', // 单文件最大 20MB
  maxFiles: '30d', // 保留 30 天
  format: logFormat,
});

配置说明

  • datePattern: 'YYYY-MM-DD-HH':按小时轮转,避免单个文件过大
  • zippedArchive: true:自动压缩为 .gz 格式,节省磁盘空间
  • maxSize: '20m':文件大小达到 20MB 时立即轮转(不必等到时间)
  • maxFiles: '30d':删除 30 天前的日志文件

6. Logger 的全局注册与使用方式

6.1 @Global() 装饰器的作用

使用 @Global() 装饰器

typescript
@Global()  // 声明为全局模块
@Module({
  imports: [WinstonModule.forRoot({...})],
  providers: [Logger],
  exports: [Logger],  // 导出 Logger 供其他模块使用
})
export class AppModule {}

@Global() 的作用

  • 使 Logger 在整个应用中可用,无需在其他模块中导入
  • 配合 exports: [Logger],其他模块可以直接注入 Logger

但是,这带来一个关键问题:

6.2 核心问题:如何在使用 Logger 的同时保留 context?

在使用依赖注入时,遇到一个两难选择

问题 1:构造函数注入 - 失去 context

typescript
// 使用 @Global() + 构造函数注入
export class UsersController {
  constructor(private readonly logger: Logger) {}

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] getUsers called ❌ 没有 [UsersController]
  }
}

问题 2:类属性方式 - 绕过依赖注入

typescript
// 使用类属性方式(不需要 @Global())
export class UsersController {
  private readonly logger = new Logger(UsersController.name);

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] [UsersController] getUsers called ✅
  }
}

关键矛盾

  • 构造函数注入:✅ 遵循 DI 原则,但 ❌ 失去 context
  • 类属性方式:✅ 保留 context,但 ❌ 绕过 DI

为什么构造函数注入会失去 context?

因为 DI 容器创建 Logger 时,无法知道当前类的名称:

typescript
// DI 容器视角
const logger = new Logger(); // 无法知道是给 UsersController 用的

// 类属性方式
const logger = new Logger(UsersController.name); // 显式传入类名 ✅

6.3 三种实用方案对比

方案 1:类属性方式 ⭐ 推荐(小型项目)
typescript
export class UsersController {
  private readonly logger = new Logger(UsersController.name);

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] [UsersController] getUsers called ✅
  }
}

优点

  • 最简洁:只需一行代码
  • 有 context:日志中显示模块名
  • 零配置:不需要 @Global()

缺点

  • 绕过 DI:不遵循依赖注入原则
  • ⚠️ 内存开销:每个类一个实例(约 200-400 字节)

适用场景:小型项目


方案 2:构造函数中创建 ⭐ 推荐(中型项目)
typescript
export class UsersController {
  private readonly logger: Logger;

  constructor() {
    this.logger = new Logger(UsersController.name);
  }

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] [UsersController] getUsers called ✅
  }
}

优点

  • 有 context:日志中显示模块名
  • 更规范:使用构造函数初始化

缺点

  • 绕过 DI:仍然不遵循依赖注入原则
  • 代码稍多:需要 4 行代码

适用场景:中型项目,注重代码规范性


方案 3:使用基类 ⭐ 推荐(大型项目)
typescript
// base.controller.ts
export abstract class BaseController {
  protected readonly logger: Logger;

  constructor() {
    // 关键:使用 this.constructor.name 自动获取子类名
    this.logger = new Logger(this.constructor.name);
  }
}

// 使用
export class UsersController extends BaseController {
  constructor() {
    super();
  }

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] [UsersController] getUsers called ✅
  }
}

优点

  • 有 context:自动获取子类名
  • 代码复用:不需要每个类都写 Logger 创建
  • 一致性:所有类使用统一模式

缺点

  • ⚠️ 需要继承:每个类必须继承基类

适用场景:大型项目,追求代码一致性


6.4 为什么不推荐构造函数注入?

代码示例

typescript
// app.module.ts
@Global()
@Module({
  providers: [Logger],
  exports: [Logger],
})
export class AppModule {}

// users.controller.ts
export class UsersController {
  constructor(private readonly logger: Logger) {}

  getUsers() {
    this.logger.log('getUsers called');
    // 输出:[LOG] getUsers called ❌ 没有 [UsersController]
  }
}

致命问题无法显示 context(模块名)

原因:DI 容器创建 Logger 时,无法知道当前类的名称


6.5 @Global() 的实际作用

关键理解

  • @Global() 只影响依赖注入系统
  • @Global() 不影响直接实例化(类属性方式)

对比

typescript
// 使用 @Global() + 构造函数注入
export class UsersController {
  constructor(private readonly logger: Logger) {}
  // ✅ 不报错,但无 context
}

// 不使用 @Global() + 构造函数注入
export class UsersController {
  constructor(private readonly logger: Logger) {}
  // ❌ 报错:Nest can't resolve dependencies
}

// 使用 @Global() + 类属性方式
export class UsersController {
  private readonly logger = new Logger(UsersController.name);
  // ✅ 正常工作
}

// 不使用 @Global() + 类属性方式
export class UsersController {
  private readonly logger = new Logger(UsersController.name);
  // ✅ 也正常工作(不依赖 DI)
}

结论:如果使用类属性方式,不需要 @Global()


实现方法

1. 请求追踪的完整链路

请求到达

LoggingMiddleware 生成 requestId

添加到 req.id 和响应头 X-Request-ID

Controller/Service 处理(可能出错)

AllExceptionsFilter 捕获异常(如果有的话)

记录日志(包含 requestId)

2. 日志级别的使用建议

级别用途示例
error系统错误、异常数据库连接失败、未捕获的异常
warn警告信息已处理的异常、降级服务
info关键业务流程用户登录、订单创建
debug调试信息函数入参、中间变量
verbose详细日志详细的执行流程

3. TypeScript 类型扩展

typescript
declare module 'express' {
  export interface Request {
    id: string; // 请求 ID
    startTime: number; // 请求开始时间
  }
}

为什么需要?

  • Express 的 Request 接口默认没有 idstartTime 属性
  • 不声明会导致 TypeScript 编译错误
  • 声明后可以在代码中安全使用这些属性

注意事项

1. 日志目录初始化

如果使用 Git,需要在 .gitignore 中添加:

gitignore
logs/
*.log

2. 敏感信息保护

生产环境不返回堆栈

typescript
{
  // 只在开发环境返回堆栈信息,生产环境不返回,防止暴露服务器代码
  ...(process.env.NODE_ENV === 'development' && stack ? { stack } : {}),
}
  • 堆栈信息可能暴露代码结构、文件路径等敏感信息
  • 日志文件中会记录完整堆栈(用于开发人员排查)
  • 前端只看到简化的错误消息

总结

本指南实现了一个完整的 NestJS Winston 日志系统,包括基础日志、访问日志、异常处理、环境区分和文件组织等功能。

关于 Logger 使用方式的关键结论

  • ⚠️ 构造函数注入会失去 context(模块名),不推荐使用
  • ✅ 推荐使用类属性方式:private readonly logger = new Logger(ClassName.name)
  • ✅ 大型项目可使用基类复用 Logger 创建逻辑
  • @Global() 只影响依赖注入,类属性方式不需要配置

核心要点:保留 context 比遵循 DI 原则更重要,优先考虑日志的可读性和可追踪性。

基于 VitePress 构建