WEBKT

NestJS 项目中 Winston 日志配置全攻略:开发、测试与生产环境的最佳实践

240 0 0 0

你好,老伙计!我是你的老朋友,一个热衷于技术分享的“老码农”。

今天,我们来聊聊 NestJS 项目中至关重要的话题——日志配置。尤其是在不同的环境(开发、测试、生产)下,如何灵活、安全地配置 Winston 日志,并遵循最佳实践。别担心,我会用最通俗易懂的方式,结合实际案例,让你轻松掌握!

为什么 Winston?

首先,为什么要选择 Winston?

Winston 是一个功能强大、灵活的日志记录库,它提供了多种日志级别、输出格式和存储方式,能满足各种复杂的日志需求。与其他日志库相比,Winston 的优势在于:

  • 灵活性高:支持自定义日志级别、格式化器、传输器(transports),可以轻松地将日志输出到控制台、文件、数据库、云服务等。
  • 易于扩展:提供了丰富的插件和中间件,可以方便地扩展 Winston 的功能,如日志压缩、日志审计等。
  • 社区活跃:拥有庞大的用户群体和活跃的社区,可以方便地找到解决方案和技术支持。

准备工作:安装 Winston 和 @nestjs/platform-express

在开始配置之前,我们需要先安装 Winston 和 NestJS 的 Express 平台。

npm install winston @nestjs/platform-express --save

安装完成后,我们就可以开始配置 Winston 了。

基础配置:创建 Winston 日志服务

首先,我们创建一个 Winston 日志服务,用于集中管理日志配置和输出。

// src/logger/winston.logger.ts
import { Injectable, LoggerService } from '@nestjs/common';
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const { combine, timestamp, printf, colorize, errors } = format;

@Injectable()
export class WinstonLoggerService implements LoggerService {
  private readonly logger;

  constructor() {
    // 自定义日志格式
    const logFormat = printf(({ level, message, timestamp, stack }) => {
      const formattedTimestamp = timestamp;
      const formattedMessage = stack ? `${message} - ${stack}` : message;
      return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
    });

    // 创建 DailyRotateFile 传输器
    const dailyRotateFileTransport = new DailyRotateFile({
      filename: 'application-%DATE%.log', // 日志文件名格式
      dirname: 'logs', // 日志文件存放目录
      datePattern: 'YYYY-MM-DD', // 日期格式
      zippedArchive: true, // 是否压缩旧的日志文件
      maxSize: '20m', // 单个日志文件的最大大小
      maxFiles: '14d', // 保留的日志文件数量
      format: combine(
        timestamp(),
        errors({ stack: true }),
        logFormat,
      ),
    });

    this.logger = createLogger({
      level: 'info', // 默认日志级别
      format: combine(
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        errors({ stack: true }),
        colorize(), // 为控制台输出添加颜色
        logFormat,
      ),
      transports: [
        new transports.Console(), // 输出到控制台
        dailyRotateFileTransport, // 输出到文件
      ],
    });
  }

  log(message: string) {
    this.logger.info(message);
  }

  error(message: string, trace: string) {
    this.logger.error(message, trace);
  }

  warn(message: string) {
    this.logger.warn(message);
  }

  debug(message: string) {
    this.logger.debug(message);
  }

  verbose(message: string) {
    this.logger.verbose(message);
  }
}

在这个例子中,我们:

  1. 导入必要的模块createLoggerformattransportsDailyRotateFile
  2. 定义日志格式:使用 printf 格式化日志输出,包括时间戳、日志级别和消息内容。
  3. 创建 DailyRotateFile 传输器:用于将日志写入文件,并进行日志轮转(按日期、大小等)。
  4. 创建 createLogger 实例:配置日志级别、格式和传输器。这里我们使用了控制台和文件两种传输器。
  5. 实现 LoggerService 接口:定义了 logerrorwarndebugverbose 方法,用于输出不同级别的日志。

在 NestJS 模块中使用 Winston 日志服务

接下来,我们需要在 NestJS 模块中使用我们创建的 Winston 日志服务。

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WinstonLoggerService } from './logger/winston.logger';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, WinstonLoggerService],
})
export class AppModule {}
// src/app.controller.ts
import { Controller, Get, Inject, LoggerService } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService,
    @Inject(WinstonLoggerService) private readonly logger: LoggerService,
  ) {}

  @Get()
  getHello(): string {
    this.logger.log('Hello World! This is a log message.');
    this.logger.error('This is an error message.', 'Something went wrong.');
    this.logger.warn('This is a warning message.');
    this.logger.debug('This is a debug message.');
    this.logger.verbose('This is a verbose message.');
    return this.appService.getHello();
  }
}

在这个例子中,我们:

  1. AppModule 中将 WinstonLoggerService 注册为 provider,这样它就可以被注入到其他组件中。
  2. AppController 中注入 WinstonLoggerService,并通过它来记录日志。

不同环境的配置策略

现在,我们来讨论在不同的环境(开发、测试、生产)下,如何配置 Winston 日志。

开发环境 (Development)

开发环境的重点在于快速迭代和调试。因此,我们需要:

  • 详细的日志信息:输出所有级别的日志,包括调试信息(debug)和详细信息(verbose)。
  • 控制台输出:方便在开发过程中查看日志。
  • 错误堆栈信息:便于快速定位问题。
  • 禁用日志轮转:避免频繁的日志文件切换,影响开发效率。
// src/logger/winston.logger.ts
import { Injectable, LoggerService } from '@nestjs/common';
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const { combine, timestamp, printf, colorize, errors } = format;

@Injectable()
export class WinstonLoggerService implements LoggerService {
  private readonly logger;

  constructor() {
    const logFormat = printf(({ level, message, timestamp, stack }) => {
      const formattedTimestamp = timestamp;
      const formattedMessage = stack ? `${message} - ${stack}` : message;
      return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
    });

    // 开发环境配置:禁用文件日志,只输出到控制台
    const transportsDev = [
      new transports.Console({
        level: 'debug',
        format: combine(
          timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
          errors({ stack: true }),
          colorize(),
          logFormat,
        ),
      }),
    ];

    this.logger = createLogger({
      level: 'debug', // 设置为 debug 级别,输出所有日志
      format: combine(
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        errors({ stack: true }),
        colorize(),
        logFormat,
      ),
      transports: transportsDev,
    });
  }

  log(message: string) {
    this.logger.info(message);
  }

  error(message: string, trace: string) {
    this.logger.error(message, trace);
  }

  warn(message: string) {
    this.logger.warn(message);
  }

  debug(message: string) {
    this.logger.debug(message);
  }

  verbose(message: string) {
    this.logger.verbose(message);
  }
}

关键修改:

  • level: 'debug':将日志级别设置为 debug,确保输出所有级别的日志。
  • transports:只配置 Console 传输器,不输出到文件。

测试环境 (Testing)

测试环境的重点在于自动化测试和问题排查。我们需要:

  • 控制台输出:方便查看测试结果和日志。
  • 简洁的日志信息:避免输出过多的调试信息,影响测试结果的可读性。
  • 可选的文件输出:根据需要,可以将日志输出到文件,方便分析测试失败原因。
// src/logger/winston.logger.ts
import { Injectable, LoggerService } from '@nestjs/common';
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const { combine, timestamp, printf, colorize, errors } = format;

@Injectable()
export class WinstonLoggerService implements LoggerService {
  private readonly logger;

  constructor() {
    const logFormat = printf(({ level, message, timestamp, stack }) => {
      const formattedTimestamp = timestamp;
      const formattedMessage = stack ? `${message} - ${stack}` : message;
      return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
    });

    // 测试环境配置:根据需要选择输出到控制台或文件
    const transportsTest = [
      new transports.Console({
        level: 'info',
        format: combine(
          timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
          errors({ stack: true }),
          colorize(),
          logFormat,
        ),
      }),
      // 可以选择开启文件输出,方便分析测试结果
      // new DailyRotateFile({
      //   filename: 'test-application-%DATE%.log',
      //   dirname: 'logs',
      //   datePattern: 'YYYY-MM-DD',
      //   zippedArchive: true,
      //   maxSize: '20m',
      //   maxFiles: '14d',
      //   format: combine(
      //     timestamp(),
      //     errors({ stack: true }),
      //     logFormat,
      //   ),
      // }),
    ];

    this.logger = createLogger({
      level: 'info', // 设置为 info 级别,输出关键信息
      format: combine(
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        errors({ stack: true }),
        colorize(),
        logFormat,
      ),
      transports: transportsTest,
    });
  }

  log(message: string) {
    this.logger.info(message);
  }

  error(message: string, trace: string) {
    this.logger.error(message, trace);
  }

  warn(message: string) {
    this.logger.warn(message);
  }

  debug(message: string) {
    this.logger.debug(message);
  }

  verbose(message: string) {
    this.logger.verbose(message);
  }
}

关键修改:

  • level: 'info':将日志级别设置为 info,输出关键信息。
  • transports:默认只配置 Console 传输器,可以根据需要开启文件输出。

生产环境 (Production)

生产环境的重点在于稳定性和安全性。我们需要:

  • 关键日志信息:只输出错误(error)和警告(warn)级别的日志,减少磁盘 I/O 压力。
  • 文件输出:将日志输出到文件,方便审计和问题排查。
  • 日志轮转:避免日志文件过大,占用磁盘空间。
  • 安全配置:保护敏感信息,如密码、API 密钥等。
// src/logger/winston.logger.ts
import { Injectable, LoggerService } from '@nestjs/common';
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const { combine, timestamp, printf, errors } = format;

@Injectable()
export class WinstonLoggerService implements LoggerService {
  private readonly logger;

  constructor() {
    const logFormat = printf(({ level, message, timestamp, stack }) => {
      const formattedTimestamp = timestamp;
      const formattedMessage = stack ? `${message} - ${stack}` : message;
      return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
    });

    // 生产环境配置:只输出 error 和 warn 级别的日志到文件
    const transportsProd = [
      new DailyRotateFile({
        filename: 'application-%DATE%.log',
        dirname: 'logs',
        datePattern: 'YYYY-MM-DD',
        zippedArchive: true,
        maxSize: '20m',
        maxFiles: '14d',
        format: combine(
          timestamp(),
          errors({ stack: true }),
          logFormat,
        ),
        level: 'warn', // 生产环境只记录 warn 和 error 级别的日志
      }),
    ];

    this.logger = createLogger({
      level: 'warn', // 设置为 warn 级别,只记录 warn 和 error 级别的日志
      format: combine(
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        errors({ stack: true }),
        logFormat,
      ),
      transports: transportsProd,
    });
  }

  log(message: string) {
    this.logger.info(message);
  }

  error(message: string, trace: string) {
    this.logger.error(message, trace);
  }

  warn(message: string) {
    this.logger.warn(message);
  }

  debug(message: string) {
    this.logger.debug(message);
  }

  verbose(message: string) {
    this.logger.verbose(message);
  }
}

关键修改:

  • level: 'warn':将日志级别设置为 warn,只输出错误和警告级别的日志。
  • transports:只配置 DailyRotateFile 传输器,将日志输出到文件。
  • 日志文件配置:设置 filenamedirnamedatePatternzippedArchivemaxSizemaxFiles 等参数,实现日志轮转。

环境配置的实现方式

那么,如何根据不同的环境来加载不同的配置呢?这里介绍几种常用的方法:

1. 环境变量

使用环境变量是最常见也是最灵活的方法。我们可以在不同的环境中设置不同的环境变量,然后在代码中读取这些变量,从而加载不同的配置。

  1. 设置环境变量

    .env 文件中设置环境变量(例如,NODE_ENV=developmentNODE_ENV=production 等)。

    # .env.development
    NODE_ENV=development
    
    # .env.production
    NODE_ENV=production
    

    注意: 实际项目中 .env 文件通常会根据不同的环境分开,比如 .env.development.env.production 等。

  2. 读取环境变量

    在 NestJS 项目中,可以使用 @nestjs/config 模块来读取环境变量。

    
    

npm install @nestjs/config --save


    ```typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule, ConfigService } from '@nestjs/config';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { WinstonLoggerService } from './logger/winston.logger';

    @Module({
      imports: [ConfigModule.forRoot()], // 导入 ConfigModule
      controllers: [AppController],
      providers: [AppService, WinstonLoggerService, ConfigService],
    })
    export class AppModule {}
    ```

    然后,在 `WinstonLoggerService` 中,我们可以通过 `ConfigService` 来读取环境变量:

    ```typescript
    // src/logger/winston.logger.ts
    import { Injectable, LoggerService } from '@nestjs/common';
    import { createLogger, format, transports } from 'winston';
    import * as DailyRotateFile from 'winston-daily-rotate-file';
    import { ConfigService } from '@nestjs/config';

    const { combine, timestamp, printf, colorize, errors } = format;

    @Injectable()
    export class WinstonLoggerService implements LoggerService {
      private readonly logger;

      constructor(private readonly configService: ConfigService) {
        const logFormat = printf(({ level, message, timestamp, stack }) => {
          const formattedTimestamp = timestamp;
          const formattedMessage = stack ? `${message} - ${stack}` : message;
          return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
        });

        const env = this.configService.get('NODE_ENV') || 'development';

        // 根据环境变量加载不同的配置
        let transports;
        if (env === 'production') {
          transports = [
            new DailyRotateFile({
              filename: 'application-%DATE%.log',
              dirname: 'logs',
              datePattern: 'YYYY-MM-DD',
              zippedArchive: true,
              maxSize: '20m',
              maxFiles: '14d',
              format: combine(
                timestamp(),
                errors({ stack: true }),
                logFormat,
              ),
              level: 'warn',
            }),
          ];
        } else {
          transports = [
            new transports.Console({
              level: 'debug',
              format: combine(
                timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
                errors({ stack: true }),
                colorize(),
                logFormat,
              ),
            }),
          ];
        }

        this.logger = createLogger({
          level: env === 'production' ? 'warn' : 'debug',
          format: combine(
            timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
            errors({ stack: true }),
            colorize(),
            logFormat,
          ),
          transports,
        });
      }

      log(message: string) {
        this.logger.info(message);
      }

      error(message: string, trace: string) {
        this.logger.error(message, trace);
      }

      warn(message: string) {
        this.logger.warn(message);
      }

      debug(message: string) {
        this.logger.debug(message);
      }

      verbose(message: string) {
        this.logger.verbose(message);
      }
    }
    ```

3.  **运行项目**:

    在不同的环境下运行项目,例如:

    ```bash
    # 开发环境
npm run start:dev

    # 生产环境
    NODE_ENV=production npm run start:prod
    ```

### 2.  配置文件

除了环境变量,我们还可以使用配置文件(如 `config.ts` 或 `config.json`)来存储不同环境的配置。这种方式更易于维护和管理,尤其是在配置项较多的时候。

1.  **创建配置文件**:

    创建一个 `config` 目录,并在其中创建不同环境的配置文件,例如:

    ```
    config/
    ├── development.ts
    ├── production.ts
    └── index.ts
    ```

    ```typescript
    // config/development.ts
    export default {
      logLevel: 'debug',
      transports: ['console'],
    };
    ```

    ```typescript
    // config/production.ts
    export default {
      logLevel: 'warn',
      transports: ['file'],
    };
    ```

    ```typescript
    // config/index.ts
    import developmentConfig from './development';
    import productionConfig from './production';

    const env = process.env.NODE_ENV || 'development';

    const config = {
      development: developmentConfig,
      production: productionConfig,
    }[env];

    export default config;
    ```

2.  **读取配置文件**:

    在 `WinstonLoggerService` 中,我们可以读取配置文件:

    ```typescript
    // src/logger/winston.logger.ts
    import { Injectable, LoggerService } from '@nestjs/common';
    import { createLogger, format, transports } from 'winston';
    import * as DailyRotateFile from 'winston-daily-rotate-file';
    import config from '../../config'; // 导入配置文件

    const { combine, timestamp, printf, colorize, errors } = format;

    @Injectable()
    export class WinstonLoggerService implements LoggerService {
      private readonly logger;

      constructor() {
        const logFormat = printf(({ level, message, timestamp, stack }) => {
          const formattedTimestamp = timestamp;
          const formattedMessage = stack ? `${message} - ${stack}` : message;
          return `${formattedTimestamp} [${level.toUpperCase()}] ${formattedMessage}`;
        });

        // 根据配置文件加载不同的配置
        let transportsConfig;
        if (config.transports.includes('console')) {
          transportsConfig = new transports.Console({
            level: config.logLevel,
            format: combine(
              timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
              errors({ stack: true }),
              colorize(),
              logFormat,
            ),
          });
        }

        const dailyRotateFileTransport = new DailyRotateFile({
          filename: 'application-%DATE%.log',
          dirname: 'logs',
          datePattern: 'YYYY-MM-DD',
          zippedArchive: true,
          maxSize: '20m',
          maxFiles: '14d',
          format: combine(
            timestamp(),
            errors({ stack: true }),
            logFormat,
          ),
          level: config.logLevel,
        });

        const transports = config.transports.includes('file') ? [transportsConfig, dailyRotateFileTransport] : [transportsConfig];

        this.logger = createLogger({
          level: config.logLevel,
          format: combine(
            timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
            errors({ stack: true }),
            colorize(),
            logFormat,
          ),
          transports,
        });
      }

      log(message: string) {
        this.logger.info(message);
      }

      error(message: string, trace: string) {
        this.logger.error(message, trace);
      }

      warn(message: string) {
        this.logger.warn(message);
      }

      debug(message: string) {
        this.logger.debug(message);
      }

      verbose(message: string) {
        this.logger.verbose(message);
      }
    }
    ```

### 3.  使用不同的日志服务实例

对于一些复杂的项目,你还可以为不同的环境创建不同的日志服务实例。这种方式可以更灵活地控制日志的输出,但是代码量会增加。

## 保护敏感信息

在日志记录中,保护敏感信息至关重要。我们需要避免将密码、API 密钥、数据库连接字符串等敏感信息记录到日志中。

1.  **过滤敏感信息**:

    在记录日志之前,对要记录的信息进行过滤,去除或替换敏感信息。例如,可以使用正则表达式或自定义函数来过滤敏感信息。

    ```typescript
    // src/logger/winston.logger.ts
    import { Injectable, LoggerService } from '@nestjs/common';
    import { createLogger, format, transports } from 'winston';
    import * as DailyRotateFile from 'winston-daily-rotate-file';

    const { combine, timestamp, printf, colorize, errors } = format;

    // 过滤敏感信息
    function filterSensitiveInfo(message: string): string {
      // 替换密码
      message = message.replace(/password: \w+/g, 'password: ***');
      // 替换 API 密钥
      message = message.replace(/apiKey: \w+/g, 'apiKey: ***');
      return message;
    }

    @Injectable()
    export class WinstonLoggerService implements LoggerService {
      private readonly logger;

      constructor() {
        const logFormat = printf(({ level, message, timestamp, stack }) => {
          const formattedTimestamp = timestamp;
          const formattedMessage = stack ? `${message} - ${stack}` : message;
          // 在输出前过滤敏感信息
          const filteredMessage = filterSensitiveInfo(formattedMessage);
          return `${formattedTimestamp} [${level.toUpperCase()}] ${filteredMessage}`;
        });

        // ... (其他配置)
      }

      log(message: string) {
        this.logger.info(message);
      }

      error(message: string, trace: string) {
        this.logger.error(filterSensitiveInfo(message), trace);
      }

      warn(message: string) {
        this.logger.warn(filterSensitiveInfo(message));
      }

      debug(message: string) {
        this.logger.debug(filterSensitiveInfo(message));
      }

      verbose(message: string) {
        this.logger.verbose(filterSensitiveInfo(message));
      }
    }
    ```

2.  **使用占位符**:

    在记录日志时,可以使用占位符来代替敏感信息。例如,可以使用 `{{password}}` 来代替密码,然后在记录日志时,将占位符替换为实际值。

    ```typescript
    this.logger.error('数据库连接失败,密码为 {{password }}', { password: '***' });
    ```

3.  **加密敏感信息**:

    如果需要在日志中记录敏感信息,可以对这些信息进行加密,并在需要时进行解密。但这种方法会增加代码复杂度,需要谨慎使用。

## 最佳实践

除了上述配置和实现方法,还有一些最佳实践可以帮助你更好地使用 Winston 日志:

*   **统一日志格式**:使用统一的日志格式,包括时间戳、日志级别、消息内容等,方便日志的检索和分析。
*   **明确日志级别**:根据不同的情况,选择合适的日志级别。例如,使用 `debug` 级别记录详细的调试信息,使用 `error` 级别记录错误信息。
*   **日志轮转**:在生产环境中,使用日志轮转功能,避免日志文件过大,占用磁盘空间。
*   **日志审计**:定期审计日志,发现潜在的安全问题和性能问题。
*   **集中式日志管理**:将日志输出到集中式日志管理系统,如 ELK Stack (Elasticsearch, Logstash, Kibana)、Splunk 等,方便日志的集中存储、检索和分析。
*   **异步日志**:对于高并发的场景,可以使用异步日志,避免日志输出阻塞应用程序的执行。
*   **异常处理**:对于未捕获的异常,使用全局异常过滤器来记录日志,确保不会丢失任何错误信息。

## 总结

好了,老伙计!今天我们一起探讨了 NestJS 项目中 Winston 日志的配置和最佳实践。希望这些内容能帮助你在开发、测试和生产环境中,更好地配置和管理日志,提高项目的稳定性和可维护性。

记住,日志是程序开发中不可或缺的一部分。合理地配置和使用日志,可以帮助我们快速定位问题、分析性能、进行安全审计,最终提升整个项目的质量。

如果你还有其他问题或想法,欢迎随时和我交流!我们下次再见!
老码农 NestJSWinston日志配置

评论点评