Nestjs 使用winston实现本地日志log记录
前言
日志的记录在后端开发中是不可或缺的,在node中有一个很有名的库winston,这个库支持多种日志传输方式,比如本地文件,控制台,远程传输等。
而nestjs有一个winston的模块库:nest-winston
既然有了模块,那说明什么?
说明我们可以通过依赖注入的形式,在其他地方直接调用模块的服务,从而实现日志记录。
在log记录之前,需要分析我们要记录什么内容,比如最重要的错误日志,当发生错误的时候,我们需要记录日志,方便后续在生产环境查看问题,其次就是请求日志记录,我们记录用户的请求,url、ip地址、路由地址、请求参数等,以及我们的响应数据,也就是发送给前端的数据,针对这三种需求我们要在不同的地方处理。
- 错误日志
错误日志我们需要通过错误过滤器来实现(所有的错误都是走的错误过滤器),在过滤器内部调用nest-winston的服务,记录我们需要的内容。
- 请求数据
用户的请求数据,我们需要自己实现一个中间件,在中间件里去获取请求的数据,考虑到中间件的生命周期,它其实天然符合我们的要求,它会在进入控制器前触发,我们通过异步的方式记录日志,也不会影响到我们后续的代码运行。
- 响应数据
这个就可以在我们之前的文章《Nestjs 格式化接口响应的数据结构》中的格式化拦截器中去做,格式化完毕后将需要的数据记录下来。
安装依赖
pnpm i nest-winston winston winston-daily-rotate-file
nest-winston
是对winston的封装;winston
日志记录功能本体;winston-daily-rotate-file
将日志文件根据日期、大小限制进行控制,并且可以根据传入的天数限制,删除旧的日志文件;
注册nest-winston模块
在app.module.ts
注册模块
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { WinstonModule } from "nest-winston";
import type { WinstonModuleOptions } from "nest-winston";
import { transports, format } from "winston";
import "winston-daily-rotate-file";
const NODE_ENV = process.env.NODE_ENV;
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: NODE_ENV === "development" ? ".env.development" : `.env.${NODE_ENV}`
}),
WinstonModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
// 日志输出的管道
const transportsList: WinstonModuleOptions["transports"] = [
new transports.DailyRotateFile({
level: "error",
dirname: `logs`,
filename: `%DATE%-error.log`,
datePattern: "YYYY-MM-DD",
maxSize: "20m"
}),
new transports.DailyRotateFile({
dirname: `logs`,
filename: `%DATE%-combined.log`,
datePattern: "YYYY-MM-DD",
maxSize: "20m",
format: format.combine(
format((info) => {
if (info.level === "error") {
return false; // 过滤掉'error'级别的日志
}
return info;
})()
)
})
];
// 开发环境下,输出到控制台
if (configService.get("NODE_ENV") === "development") {
transportsList.push(new transports.Console());
}
return {
transports: transportsList
};
}
})
],
controllers: [],
providers: []
})
export class AppModule {
}
需要注意的是Winston的日志level等级的定义:
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
verbose: 4,
debug: 5,
silly: 6
};
当我们配置level为wran的时候,它会记录小于或等于当前等级的日志信息,这就导致了一个问题,error是最小的,所以在combined.log
日志里面,它是会有error的日志写入的,但是我们又单独创建了一个error日志文件专门存放,所以我们得想办法剔除它。
目前我能找到的办法就是通过format
方法进行排除。
如果你想了解更多输出管道的options配置,可以查看官方文档:winston
如果你想了解日志文件控制 winston-daily-rotate-file
的配置项,可以查看这个文档: winston-daily-rotate-file
错误日志记录
我们找到全局的错误过滤器:http-exception.filter.ts
import { BadRequestException, Catch, ExceptionFilter, HttpException, Inject } from "@nestjs/common";
import type { ArgumentsHost } from "@nestjs/common";
import type { Response, Request } from "express";
import { getReasonPhrase } from "http-status-codes";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";
import { Logger } from "winston";
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const results = exception.getResponse() as any;
const code = results.statusCode;
// 返回的对象
const jsonData = {
code: code,
message: results.message || getReasonPhrase(code),
data: null
};
// 参数校验错误,默认都是BadRequestException
const isArrayMessage = Array.isArray(results.message);
const isValidationError =
isArrayMessage && typeof results.message[0] === "string" && results.message[0].includes("⓿");
if (exception instanceof BadRequestException && isValidationError) {
const message: Array<{ field: string; message: Array<string> }> = [];
results.message.forEach((item) => {
const [key, val] = item.split("⓿") as [string, string];
const findData = message.find((item) => item.field === key);
if (findData) {
findData.message.push(val);
} else {
message.push({ field: key, message: [val] });
}
});
jsonData.message = message;
}
// 记录日志
const { method, originalUrl, body, query, params, ip } = request;
this.logger.error("HttpException", {
res: {
code,
status,
message: jsonData.message
},
req: {
method,
url: originalUrl,
body,
query,
params,
ip
}
});
return response.status(status).json(jsonData);
}
}
在constructor构造函数中我们注入了logger服务,然后调用它的error等级的日志方法,记录数据,一般记录日志的方法,第一个参数为type类型,后面的参数则是具体的内容。
由于我们使用了注入,所以在注册该过滤器的时候,需要在app.module.ts
的providers注入,至于为什么?之前的文章已经讲过了,这里不多赘述。
app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { HttpExceptionFilter } from "@/utils/filters";
@Module({
imports: [],
controllers: [],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
}
]
})
export class AppModule {
}
请求数据记录
先创建一个中间件
nest g mi logger utils/middleware --no-spec
logger
是中间件的名称utils/middleware
是中间件存放的位置
import { Inject, Injectable, NestMiddleware } from "@nestjs/common";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";
import { Logger } from "winston";
import { NextFunction, Request, Response } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
use(req: Request, res: Response, next: NextFunction) {
const { method, originalUrl, body, query, params, ip } = req;
// 记录日志
this.logger.info("router", {
req: {
method,
url: originalUrl,
body,
query,
params,
ip
}
});
next();
}
}
相对来说还是很简单的,我们从req对象中,也就是请求对象中解构出需要的对象,然后日志记录即可。
然后我们去app.module.ts
激活。
app.module.ts
import { Module } from "@nestjs/common";
import type { MiddlewareConsumer } from "@nestjs/common";
import { LoggerMiddleware } from "@/utils/middleware";
@Module({
imports: [],
controllers: [],
providers: []
})
export class AppModule {
// 全局中间件
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
}
}
中间件的注入是在AppModule
类的configure
方法中的,其中forRoutes
表示中间件需要作用的路由有哪些,你可以是具体的/test
,也可以是表示所有的*
,甚至可以是一个函数,具体你可以查看官方文档:中间件
响应数据记录
找到我们之前的拦截器:result-format.interceptor.ts
import { hasKeys } from "@/utils/tools";
import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from "@nestjs/common";
import type { Response, Request } from "express";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { getReasonPhrase } from "http-status-codes";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";
import { Logger } from "winston";
@Injectable()
export class ResultFormatInterceptor implements NestInterceptor {
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const code = response.statusCode;
const message = response.statusMessage || getReasonPhrase(code);
return next.handle().pipe(
map((data) => {
let jsonData = { code: code, message: message, data: data ?? null };
// 判断是否已经是格式化的数据
if (data) {
const hasFormat = hasKeys(data, ["code", "message", "data"]);
if (hasFormat) jsonData = data;
}
// 记录日志
const { method, originalUrl, body, query, params, ip } = request;
this.logger.info("response", {
req: {
method,
url: originalUrl,
body,
query,
params,
ip
},
res: jsonData
});
return jsonData;
})
);
}
}
其实也没什么复杂的,也是依赖注入,然后调用info记录。
由于使用了依赖注入,我们一样需要在app.module.ts
中注册。
app.module.ts
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";
import { ResultFormatInterceptor } from "@/utils/interceptor";
@Module({
imports: [],
controllers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResultFormatInterceptor
},
]
})
export class AppModule {
}
结尾
至此我们日志记录的代码都写完了,启动nest,它会在项目目录创建logs目录,然后里面就会有对应的文件:
# logs/
2024-01-13-combined.log
2024-01-13-error.log
我们测试api请求,就可以在对应的日志文件中查看记录的数据。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据