NestJS Winston 日志系统实现指南
本文档介绍在 NestJS 中集成 Winston 日志系统的完整实现方法,包括基础集成、访问日志记录、错误处理、日志组织等核心知识点。
功能概述
本指南实现一个完整的 NestJS 日志系统,包含以下功能:
- 基础日志记录(终端输出 + 文件存储)
- HTTP 请求日志记录(中间件实现)
- 全局异常捕获与记录(过滤器实现)
- 按环境区分日志格式
- 规范的日志文件组织结构
技术栈
{
"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:
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 - 用于记录业务日志:
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 请求:
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 开发环境 - 文本格式
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() called2.2 生产环境 - JSON 格式
const createJsonLogFormat = () => {
return winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
);
};输出示例:
{
"timestamp": "2026-03-23T08:00:00.000Z",
"level": "info",
"message": "users.controller.getUsers() called",
"context": "UsersController"
}2.3 动态选择格式
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 中间件实现
核心功能:
- 生成请求 ID:
import { v4 as uuidv4 } from 'uuid';
req.id = uuidv4().split('-')[0]; // 使用 UUID 前 8 位
req.startTime = Date.now();
// 添加请求 ID 到响应头(便于前端追踪)
res.setHeader('X-Request-ID', requestId);- 记录请求开始:
this.logger.info(`${method} ${originalUrl} - ${ip} - ${userAgent}`, {
requestId,
context: 'LoggingMiddleware',
});- 监听响应结束:
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 注册中间件
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggingMiddleware).forRoutes('*'); // 拦截所有路由
}
}为什么在 finish 事件中记录?
- 此时响应已完成,可以获取完整的响应信息(状态码、响应时间等)
finish事件在响应发送给客户端后触发,不会影响响应性能
为什么要用独立的 Logger?
- 访问日志数据量大,单独存储避免污染业务日志
- 便于日志分析和监控系统单独处理访问日志
- 可以设置不同的保留策略(如访问日志只保留 7 天)
4. 全局异常过滤器
4.1 过滤器实现
核心逻辑:
- 捕获所有异常:
@Catch() // 不传参数则捕获所有异常
export class AllExceptionsFilter implements ExceptionFilter- 区分异常类型:
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 提取错误消息
const exceptionResponse = isHttpException
? exception.getResponse()
: { message: 'Internal server error' };- 记录错误日志:
this.logger.error(logMessage, {
requestId, // 关联请求
statusCode: status,
method: request.method,
url: request.originalUrl,
ip: request.ip,
stack, // 堆栈信息
context: 'AllExceptionsFilter',
});- 统一响应格式:
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 注册过滤器
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.log5.2 分层策略
| 目录 | 日志级别 | 保留时间 | 用途 |
|---|---|---|---|
| access | info | 7天 | 访问日志,量大但价值低 |
| application | info/warn/error | 30天 | 业务日志,常规排查 |
| errors | error | 60天 | 错误日志,长期追踪 |
为什么这样分层?
- 快速定位:出问题时直接查看
errors/目录 - 存储优化:不同价值的日志设置不同保留期限
- 分析便利:访问日志单独存储,便于流量分析
5.3 日志轮转配置
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() 装饰器:
@Global() // 声明为全局模块
@Module({
imports: [WinstonModule.forRoot({...})],
providers: [Logger],
exports: [Logger], // 导出 Logger 供其他模块使用
})
export class AppModule {}@Global() 的作用:
- 使 Logger 在整个应用中可用,无需在其他模块中导入
- 配合
exports: [Logger],其他模块可以直接注入 Logger
但是,这带来一个关键问题:
6.2 核心问题:如何在使用 Logger 的同时保留 context?
在使用依赖注入时,遇到一个两难选择:
问题 1:构造函数注入 - 失去 context
// 使用 @Global() + 构造函数注入
export class UsersController {
constructor(private readonly logger: Logger) {}
getUsers() {
this.logger.log('getUsers called');
// 输出:[LOG] getUsers called ❌ 没有 [UsersController]
}
}问题 2:类属性方式 - 绕过依赖注入
// 使用类属性方式(不需要 @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 时,无法知道当前类的名称:
// DI 容器视角
const logger = new Logger(); // 无法知道是给 UsersController 用的
// 类属性方式
const logger = new Logger(UsersController.name); // 显式传入类名 ✅6.3 三种实用方案对比
方案 1:类属性方式 ⭐ 推荐(小型项目)
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:构造函数中创建 ⭐ 推荐(中型项目)
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:使用基类 ⭐ 推荐(大型项目)
// 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 为什么不推荐构造函数注入?
代码示例:
// 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()不影响直接实例化(类属性方式)
对比:
// 使用 @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 类型扩展
declare module 'express' {
export interface Request {
id: string; // 请求 ID
startTime: number; // 请求开始时间
}
}为什么需要?
- Express 的
Request接口默认没有id和startTime属性 - 不声明会导致 TypeScript 编译错误
- 声明后可以在代码中安全使用这些属性
注意事项
1. 日志目录初始化
如果使用 Git,需要在 .gitignore 中添加:
logs/
*.log2. 敏感信息保护
生产环境不返回堆栈:
{
// 只在开发环境返回堆栈信息,生产环境不返回,防止暴露服务器代码
...(process.env.NODE_ENV === 'development' && stack ? { stack } : {}),
}- 堆栈信息可能暴露代码结构、文件路径等敏感信息
- 日志文件中会记录完整堆栈(用于开发人员排查)
- 前端只看到简化的错误消息
总结
本指南实现了一个完整的 NestJS Winston 日志系统,包括基础日志、访问日志、异常处理、环境区分和文件组织等功能。
关于 Logger 使用方式的关键结论:
- ⚠️ 构造函数注入会失去 context(模块名),不推荐使用
- ✅ 推荐使用类属性方式:
private readonly logger = new Logger(ClassName.name) - ✅ 大型项目可使用基类复用 Logger 创建逻辑
- ✅
@Global()只影响依赖注入,类属性方式不需要配置
核心要点:保留 context 比遵循 DI 原则更重要,优先考虑日志的可读性和可追踪性。