NestJS 日志进阶:集成 Winston/Pino,玩转请求上下文与链路追踪
NestJS 日志进阶:集成 Winston/Pino,玩转请求上下文与链路追踪
为什么我们需要一个强大的日志系统?
Winston 和 Pino:两大日志库的对比
NestJS 日志系统的核心:LoggerService
实战:集成 Winston
1. 安装依赖
2. 创建自定义 Logger
3. 在 AppModule 中配置
4. 在控制器中使用
添加请求上下文信息
1. 创建请求 ID 中间件
2. 在 AppModule 中应用中间件
3. 修改 WinstonLogger,添加请求 ID
4. 利用 AsyncLocalStorage 存储请求上下文
实现链路追踪
集成 Pino
1. 安装依赖
2. 创建自定义 Logger
3. 在 AppModule 中配置
4. 添加 pino-http 中间件 (可选,但强烈推荐)
总结
NestJS 日志进阶:集成 Winston/Pino,玩转请求上下文与链路追踪
大家好,我是你们的“老伙计”码农小助手。今天咱们来聊聊 NestJS 开发中一个非常重要,但又经常被忽视的环节——日志系统。相信不少开发者在日常开发中,都遇到过线上问题难以排查的窘境,而一个完善的日志系统,正是解决这一问题的关键。
你是不是也遇到过这样的情况:
- 线上出现了一个偶发性 bug,但日志里除了报错信息,啥也没有,完全不知道这个请求经历了什么。
- 多个请求同时发生,日志混杂在一起,根本分不清哪个是哪个,排查起来像大海捞针。
- 想做全链路追踪,却不知道从何下手,每次都要手动在各个服务里加一堆追踪 ID。
别担心,今天我就带你一起,用 NestJS 结合流行的日志库 Winston 和 Pino,打造一个强大的日志系统,彻底解决这些烦恼!
为什么我们需要一个强大的日志系统?
在传统的开发模式下,console.log
大法或许还能勉强应付。但是,随着微服务架构的兴起,以及系统复杂度的不断提升,console.log
已经远远不够用了。一个好的日志系统,至少应该具备以下几个特点:
- 分级记录: 能够根据日志的严重程度(DEBUG、INFO、WARN、ERROR 等)进行分类,方便开发者快速筛选。
- 结构化输出: 日志信息以结构化的方式(如 JSON)输出,方便机器解析和后续处理。
- 持久化存储: 日志能够被持久化存储到文件、数据库或专业的日志服务中,方便长期保存和分析。
- 上下文信息: 能够记录请求的上下文信息,如请求 ID、用户 ID、请求参数等,方便问题定位。
- 链路追踪: 在微服务架构下,能够追踪一个请求在各个服务之间的调用链,快速定位问题源头。
Winston 和 Pino:两大日志库的对比
Winston 和 Pino 都是 Node.js 社区中非常流行的日志库,它们各有千秋,我们先来简单对比一下:
特性 | Winston | Pino |
---|---|---|
灵活性 | 高,支持多种传输器(transports)和自定义格式 | 相对较低,但性能更高 |
性能 | 相对较低 | 非常高,专为高性能而设计 |
社区活跃度 | 高 | 高 |
易用性 | 相对复杂 | 更简单,开箱即用 |
扩展性 | 通过传输器和格式化器进行扩展 | 通过插件进行扩展 |
总的来说,Winston 更灵活,功能更丰富,但配置也更复杂;Pino 则更注重性能,配置更简单。在实际项目中,你可以根据自己的需求进行选择。不过,无论选择哪个,NestJS 都能够很好地与它们集成。
NestJS 日志系统的核心:LoggerService
NestJS 提供了一个内置的 LoggerService
接口,它定义了日志记录的基本方法(log
、error
、warn
、debug
、verbose
)。NestJS 默认的 Logger
类实现了这个接口,但它只是简单地将日志输出到控制台。在实际项目中,我们通常需要自定义一个 LoggerService
,来实现更强大的日志功能。
实战:集成 Winston
1. 安装依赖
npm install winston @nestjs/common
2. 创建自定义 Logger
// src/logger/winston.logger.ts import { LoggerService } from '@nestjs/common'; import * as winston from 'winston'; export class WinstonLogger implements LoggerService { private readonly logger: winston.Logger; constructor(private readonly context?: string) { this.logger = winston.createLogger({ level: 'info', // 设置日志级别 format: winston.format.combine( winston.format.timestamp(), // 添加时间戳 winston.format.json() // 使用 JSON 格式 ), transports: [ new winston.transports.Console(), // 输出到控制台 new winston.transports.File({ filename: 'logs/combined.log' }), // 输出到文件 new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), // 错误日志单独输出 ], }); } log(message: string, context?: string) { this.logger.info(message, { context: context || this.context }); } error(message: string, trace?: string, context?: string) { this.logger.error(message, { trace, context: context || this.context }); } warn(message: string, context?: string) { this.logger.warn(message, { context: context || this.context }); } debug(message: string, context?: string) { this.logger.debug(message, { context: context || this.context }); } verbose(message: string, context?: string) { this.logger.verbose(message, { context: context || this.context }); } }
3. 在 AppModule 中配置
// src/app.module.ts import { Module, Logger } from '@nestjs/common'; import { WinstonLogger } from './logger/winston.logger'; @Module({ providers: [ { provide: Logger, useClass: WinstonLogger, // 使用自定义的 WinstonLogger }, ], }) export class AppModule {}
4. 在控制器中使用
// src/app.controller.ts import { Controller, Get, Logger } from '@nestjs/common'; @Controller() export class AppController { private readonly logger = new Logger(AppController.name); // 在构造函数中,建议传入当前类的名称作为 context @Get() getHello(): string { this.logger.log('Handling getHello request...'); return 'Hello World!'; } }
现在,你的 NestJS 应用已经集成了 Winston,日志会同时输出到控制台和文件中。但是,这还远远不够,我们还需要实现请求上下文和链路追踪。
添加请求上下文信息
每次请求都应该有唯一的标识符,以及一些其他的上下文信息,如用户 ID、请求参数等。这些信息可以帮助我们更好地理解请求的执行过程,并在出现问题时快速定位。
1. 创建请求 ID 中间件
// src/middleware/request-id.middleware.ts import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class RequestIdMiddleware implements NestMiddleware { private readonly logger = new Logger(RequestIdMiddleware.name); use(req: Request, res: Response, next: NextFunction) { const requestId = uuidv4(); req['requestId'] = requestId; // 将 requestId 添加到 req 对象中 res.setHeader('X-Request-Id', requestId); // 将 requestId 添加到响应头中 this.logger.log(`Request ID: ${requestId}`); next(); } }
2. 在 AppModule 中应用中间件
// src/app.module.ts import { Module, NestModule, MiddlewareConsumer, Logger } from '@nestjs/common'; import { RequestIdMiddleware } from './middleware/request-id.middleware'; import { WinstonLogger } from './logger/winston.logger'; @Module({ providers: [ { provide: Logger, useClass: WinstonLogger, // 使用自定义的 WinstonLogger }, ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(RequestIdMiddleware).forRoutes('*'); // 对所有路由应用中间件 } }
3. 修改 WinstonLogger,添加请求 ID
// src/logger/winston.logger.ts // ... (之前的代码) log(message: string, context?: string) { const requestId = this.getRequestId(); this.logger.info(message, { context: context || this.context, requestId }); } error(message: string, trace?: string, context?: string) { const requestId = this.getRequestId(); this.logger.error(message, { trace, context: context || this.context, requestId }); } //...其他方法类似 private getRequestId() { // 从 AsyncLocalStorage 中获取 requestId return (this as any)._httpContext?.getRequestId?.(); } //... (之后的代码)
4. 利用 AsyncLocalStorage
存储请求上下文
由于 Node.js 的异步特性,我们需要使用 AsyncLocalStorage
来存储每个请求的上下文信息。AsyncLocalStorage
是 Node.js 提供的一个 API,可以在异步调用之间共享数据。
首先需要安装: npm install --save @nestjs/core
// src/logger/winston.logger.ts import { LoggerService, Scope, Inject, Injectable } from '@nestjs/common'; import * as winston from 'winston'; import { AsyncLocalStorage } from 'async_hooks'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; @Injectable({ scope: Scope.REQUEST }) export class WinstonLogger implements LoggerService { private readonly logger: winston.Logger; private readonly asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>(); constructor(@Inject(REQUEST) private readonly request: Request) { this.logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'logs/combined.log' }), new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), ], }); } log(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); this.logger.info(message, { context: context || this.context, requestId }); } // 其他方法类似地添加 requestId error(message: string, trace?: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); this.logger.error(message, { trace, context: context || this.context, requestId }); } warn(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); this.logger.warn(message, { context: context || this.context, requestId }); } debug(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); this.logger.debug(message, { context: context || this.context, requestId }); } verbose(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); this.logger.verbose(message, { context: context || this.context, requestId }); } setRequestId(requestId: string) { const store = this.asyncLocalStorage.getStore() || new Map(); store.set('requestId', requestId); this.asyncLocalStorage.enterWith(store); } }
// src/middleware/request-id.middleware.ts // ...之前的代码 @Injectable() export class RequestIdMiddleware implements NestMiddleware { private readonly logger = new Logger(RequestIdMiddleware.name); constructor(private readonly loggerService: WinstonLogger) {} use(req: Request, res: Response, next: NextFunction) { const requestId = uuidv4(); req['requestId'] = requestId; res.setHeader('X-Request-Id', requestId); this.loggerService.setRequestId(requestId); // 设置 requestId this.logger.log(`Request ID: ${requestId}`); next(); } }
现在,你的日志中就会包含每个请求的唯一 ID 了。你还可以根据需要,添加其他的上下文信息,如用户 ID、请求参数等。
实现链路追踪
在微服务架构下,一个请求可能会经过多个服务,我们需要一种机制来追踪这个请求在各个服务之间的调用链。这就是链路追踪的作用。
实现链路追踪的方法有很多,这里我们介绍一种基于 HTTP Header 的简单实现:
- 在入口服务(如 API 网关)生成一个全局唯一的 Trace ID。
- 将 Trace ID 添加到 HTTP Header 中,向下游服务传递。
- 下游服务接收到请求后,从 Header 中提取 Trace ID,并继续向下游传递。
- 在每个服务的日志中,都记录 Trace ID。
这样,我们就可以通过 Trace ID 将一个请求在各个服务中的日志串联起来,形成一个完整的调用链。
在 NestJS 中,我们可以使用拦截器(Interceptor)来实现 Trace ID 的传递:
// src/interceptor/trace-id.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class TraceIdInterceptor implements NestInterceptor { private readonly logger = new Logger(TraceIdInterceptor.name); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); let traceId = request.headers['x-trace-id']; if (!traceId) { traceId = uuidv4(); request.headers['x-trace-id'] = traceId; // 将 Trace ID 添加到请求头中 } this.logger.log(`Trace ID: ${traceId}`); return next.handle().pipe( tap(() => { // 在响应中也添加 Trace ID context.switchToHttp().getResponse().setHeader('X-Trace-Id', traceId); }) ); } }
在 AppModule
中全局注册拦截器:
// app.module.ts import { APP_INTERCEPTOR } from '@nestjs/core'; import { TraceIdInterceptor } from './trace-id.interceptor'; @Module({ providers: [ { provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor, }, ], }) export class AppModule {}
然后在 WinstonLogger
中,将 traceId
添加到日志:
// src/logger/winston.logger.ts // ... (之前的代码) log(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.info(message, { context: context || this.context, requestId, traceId }); } //...其他方法做类似处理。
现在,你的日志中就会同时包含 requestId
和 traceId
了。requestId
用于标识同一个请求在同一个服务中的不同阶段,traceId
用于标识同一个请求在不同服务之间的调用链。
集成 Pino
集成 Pino 的过程与 Winston 类似,主要区别在于配置方式。
1. 安装依赖
npm install pino pino-http @nestjs/common
2. 创建自定义 Logger
// src/logger/pino.logger.ts import { LoggerService, Scope, Inject, Injectable } from '@nestjs/common'; import pino from 'pino'; import { AsyncLocalStorage } from 'async_hooks'; import { REQUEST } from '@nestjs/core'; import { Request, Response } from 'express'; @Injectable({ scope: Scope.REQUEST }) export class PinoLogger implements LoggerService { private readonly logger: pino.Logger; private readonly asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>(); constructor(@Inject(REQUEST) private readonly request: Request) { this.logger = pino({ level: 'info', prettyPrint: process.env.NODE_ENV !== 'production', // 开发环境开启 prettyPrint redact: ['req.headers.authorization'], // 敏感信息脱敏 }); } log(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.info({ context: context || this.context, requestId, traceId }, message); } // 其他方法类似地添加 requestId 和 traceId error(message: string, trace?: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.error({ context: context || this.context, requestId, traceId, trace }, message); } warn(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.warn({ context: context || this.context, requestId, traceId }, message); } debug(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.debug({ context: context || this.context, requestId, traceId }, message); } verbose(message: string, context?: string) { const store = this.asyncLocalStorage.getStore(); const requestId = store?.get('requestId'); const traceId = this.request.headers['x-trace-id']; this.logger.verbose({ context: context || this.context, requestId, traceId }, message); } setRequestId(requestId: string) { const store = this.asyncLocalStorage.getStore() || new Map(); store.set('requestId', requestId); this.asyncLocalStorage.enterWith(store); } }
3. 在 AppModule 中配置
// src/app.module.ts import { Module, Logger } from '@nestjs/common'; import { PinoLogger } from './logger/pino.logger'; @Module({ providers: [ { provide: Logger, useClass: PinoLogger, // 使用自定义的 PinoLogger }, ], }) export class AppModule {}
4. 添加 pino-http
中间件 (可选,但强烈推荐)
pino-http
是一个专门为 HTTP 请求设计的 Pino 中间件,它可以自动记录请求和响应的信息,并与 PinoLogger
无缝集成。
// src/app.module.ts import { Module, NestModule, MiddlewareConsumer, Logger } from '@nestjs/common'; import { PinoLogger } from './logger/pino.logger'; import { RequestIdMiddleware } from './middleware/request-id.middleware'; import pinoHttp from 'pino-http'; @Module({ providers: [ { provide: Logger, useClass: PinoLogger, }, ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(RequestIdMiddleware).forRoutes('*'); consumer .apply( pinoHttp({ logger: new PinoLogger({} as any).logger, // 传入 PinoLogger 实例 // customProps: (req, res) => ({ // // 自定义属性 // }), // autoLogging: false, // 可以选择关闭自动日志 }) ) .forRoutes('*'); } }
现在,你的 NestJS 应用已经集成了 Pino,并且可以自动记录请求和响应的信息,以及请求 ID 和 Trace ID。
总结
通过本文的学习,相信你已经掌握了如何在 NestJS 中集成 Winston 和 Pino,并实现请求上下文和链路追踪。这些技能可以帮助你构建更健壮、更易于维护的应用程序。记住,一个好的日志系统是系统稳定运行的基石,也是开发者排查问题的利器。希望你在实际项目中能够灵活运用这些知识,让你的应用“日”久弥新!