前言

日志的记录在后端开发中是不可或缺的,在node中有一个很有名的库winston,这个库支持多种日志传输方式,比如本地文件,控制台,远程传输等。

而nestjs有一个winston的模块库:nest-winston

既然有了模块,那说明什么?

说明我们可以通过依赖注入的形式,在其他地方直接调用模块的服务,从而实现日志记录。

在log记录之前,需要分析我们要记录什么内容,比如最重要的错误日志,当发生错误的时候,我们需要记录日志,方便后续在生产环境查看问题,其次就是请求日志记录,我们记录用户的请求,url、ip地址、路由地址、请求参数等,以及我们的响应数据,也就是发送给前端的数据,针对这三种需求我们要在不同的地方处理。

  1. 错误日志

错误日志我们需要通过错误过滤器来实现(所有的错误都是走的错误过滤器),在过滤器内部调用nest-winston的服务,记录我们需要的内容。

  1. 请求数据

用户的请求数据,我们需要自己实现一个中间件,在中间件里去获取请求的数据,考虑到中间件的生命周期,它其实天然符合我们的要求,它会在进入控制器前触发,我们通过异步的方式记录日志,也不会影响到我们后续的代码运行。

  1. 响应数据

这个就可以在我们之前的文章《Nestjs 格式化接口响应的数据结构》中的格式化拦截器中去做,格式化完毕后将需要的数据记录下来。

安装依赖

pnpm i nest-winston winston winston-daily-rotate-file
  1. nest-winston是对winston的封装;
  2. winston日志记录功能本体;
  3. 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
  1. logger是中间件的名称
  2. 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请求,就可以在对应的日志文件中查看记录的数据。

分类: Nest.js 标签: Nestjswinston日志log

评论

暂无评论数据

暂无评论数据

目录