NestJS 简单入门(五)保存 Log 为文件

NestJS 简单入门(五)保存 Log 为文件

前言

到这里,我们的后台项目的骨架就基本完成了,在最后我们需要给项目增加一点点"记忆"------将项目中的部分 log 信息保存到本地文件中。本章主要使用winston来将日志记录到文件中。

一般来说,为了方便排查问题,log 中会记录请求的信息,报错的信息,和服务器的返回信息,分别对应了 NestJS 中的中间件、过滤器和拦截器,因此我们主要改造这三个部分。

安装 winston

winston是 Node.js 中非常流行的日志记录库,可以通过配置将日志记录到控制台、文件、数据库等不同目标中。

安装:

bash 复制代码
 pnpm install --save nest-winston winston winston-daily-rotate-file

我们安装了三个包,一个是主角winston,一个是nest-winston,这个包将winston封装成了 NestJS 的 Module,就不需要我们二次封装了,还有一个是winston-daily-rotate-file,这个包主要是用来做日志文件的归档的,可以自动将日志按时间或日期等规则进行分割,避免日志都记录在一个巨大的文件中。

在项目中引入 winston

类似与 TypeORM 或 Redis,我们也需要在app.module.ts中注册winston

ts 复制代码
 // app.module.ts
 ​
 ...
 import { WinstonModule } from 'nest-winston';
 import 'winston-daily-rotate-file';
 import { transports } from 'winston';
 ​
 @Module({
   imports: [
     ...
     WinstonModule.forRoot({
       transports: [
         new transports.DailyRotateFile({
           dirname: `logs`,
           filename: `%DATE%.log`,
           datePattern: 'YYYY-MM-DD',
           zippedArchive: true,
           maxSize: '20m',
           maxFiles: '14d',
         }),
       ],
     }),
   ],
   ...
 })
 export class AppModule {}
 ​

这里我们将 log 文件命名为%DATE%.log,并存储在根目录的logs中。%DATE%最终会被datePattern的值替换,也就是说,最后 log 文件会以logs/2023-08-01.log的形式保存,可以根据自己的需要自定义。

zippedArchive表示是否需要用 gzip 方式压缩文件,默认为false

maxSizemaxFiles很好理解,设置单个日志文件最大为 20MB,日志文件最长保存 14 天。

此时保存并重启项目,可以看到logs目录已经生成了。

arduino 复制代码
 .
 ├── README.md
 ├── config
 ├── dist
 ├── logs              //  在这里
 ├── nest-cli.json
 ├── node_modules
 ├── package.json
 ├── pnpm-lock.yaml
 ├── src
 ├── test
 ├── tsconfig.build.json
 └── tsconfig.json
 ​
 7 directories, 6 files

添加中间件

我们需要记录每个请求的信息,比如请求的 IP,请求的 URL 等,我们可以通过中间件来实现这个功能。

新建一个中间件:

bash 复制代码
 nest g mi logger global/middleware

编写我们的逻辑:

ts 复制代码
 // /src/global/middleware/logger.middleware.ts
 ​
 import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
 import { NextFunction, Request, Response } from 'express';
 import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
 import { Logger } from 'winston';
 ​
 @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: url, body, query, params, ip } = req;
     this.logger.info('route', {
       req: {
         method,
         url,
         body,
         query,
         params,
         ip,
       },
     });
     next();
   }
 }
 ​

这里我们从请求中取出了method, originalUrl, body, query, params, ip并记录在日志中,各位读者也可以根据自己的需要进行修改。

注意 :在①处,@Inject()中别忘记传入WINSTON_MODULE_PROVIDER,同时后面的Logger也要引入winston包中的,因为 NestJS 自身也会导出一个Logger,注意区分。

创建了中间件之后,我们需要在app.module.ts中应用一下:

ts 复制代码
 // app.module.ts
 ​
 import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
 import { LoggerMiddleware } from './global/middleware/logger/logger.middleware';
 ...
 ...
 ...
 export class AppModule implements NestModule {
   configure(consumer: MiddlewareConsumer) {
     consumer.apply(LoggerMiddleware).forRoutes('*');
   }
 }

这里我们将中间件LoggerMiddleware应用在*即所有路由上。

我们随便请求一个接口,然后打开logs目录下 log 文件查看:

json 复制代码
 // logs/2023-08-01.log
 ​
 {"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}

没有问题。

改造错误过滤器

废话少说,直接开改:

ts 复制代码
 // /src/global/filter/http-exception.filter.ts
 ​
 import {
   ArgumentsHost,
   Catch,
   ExceptionFilter,
   HttpException,
   Inject,
 } from '@nestjs/common';
 import { Response } from 'express';
 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 {
       method,
       originalUrl: url,
       body,
       query,
       params,
       ip,
     } = ctx.getRequest();
     this.logger.error(message, {
       status,
       req: { method, originalUrl: url, body, query, params, ip },
     });
   }
 }
 ​

从 22 行到 29 行的代码简单且重复,因此抽离出来封装成一个函数:

ts 复制代码
// /src/global/helper/getInfoFromReq.ts

import { Request } from 'express';

export const getInfoFromReq = (req: Request) => {
  const { method, originalUrl: url, body, query, params, ip } = req;
  return {
    method,
    url,
    body,
    query,
    params,
    ip,
  };
};

然后重新修改错误过滤器:

ts 复制代码
// /src/global/filter/http-exception.filter.ts

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  Inject,
} from '@nestjs/common';
import { Response } from 'express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { getInfoFromReq } from 'src/global/helper/getInfoFromReq';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}
  catch(exception: HttpException, host: ArgumentsHost) {
    ...

    this.logger.error(message, {
      status,
      req: getInfoFromReq(ctx.getRequest()),
    });
  }
}

不要忘记中间件中的代码也可以用工具函数getInfoFromReq()进行精简:

ts 复制代码
// /src/global/middleware/logger.middleware.ts

import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { getInfoFromReq } from 'src/global/helper/getInfoFromReq';
import { Logger } from 'winston';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}
  use(req: Request, res: Response, next: NextFunction) {
    this.logger.info('route', {
      req: getInfoFromReq(req),
    });
    next();
  }
}

这时发现main.ts中引用HttpExceptionFilter的地方报错了:

我们删除该行代码,改为在app.module.ts中注册:

ts 复制代码
// main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { TransformInterceptor } from './global/interceptor/transform/transform.interceptor';
// import { HttpExceptionFilter } from './global/filter/http-exception/http-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalInterceptors(new TransformInterceptor());
  // app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();
ts 复制代码
// app.module.ts

...
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { HttpExceptionFilter } from './global/filter/http-exception/http-exception.filter';

@Module({
  ...
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
    {
      provide: APP_FILTER,							// 在这里注册
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

测试一下:

bash 复制代码
curl --location --request GET 'http://localhost:3000/user/1' 

响应:

json 复制代码
{
    "code": 401,
    "message": "token已过期",
    "content": {}
}

查看我们的 log:

json 复制代码
{"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}
{"level":"info","message":"route","req":{"body":{},"ip":"::1","method":"GET","params":{"0":"user/1"},"query":{},"url":"/user/1"}}
{"level":"error","message":"token已过期","req":{"body":{},"ip":"::1","method":"GET","params":{"id":"1"},"query":{},"url":"/user/1"},"status":401}

可以看到,错误也正确的记录了下来。

改造响应拦截器

废话少说,开改:

ts 复制代码
// /src/global/interceptor/transform.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Inject,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Observable, map } from 'rxjs';
import { Logger } from 'winston';
import { getInfoFromReq } from 'src/global/helper/getInfoFromReq';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
  ) {}
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        this.logger.info('response', {
          responseData: data,
          req: getInfoFromReq(context.switchToHttp().getRequest()),
        });
        return {
          code: 0,
          message: '请求成功',
          data,
        };
      }),
    );
  }
}

同样的,我们也要将响应拦截器的注册位置从main.ts转移到app.module.ts中:

ts 复制代码
// main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
// import { TransformInterceptor } from './global/interceptor/transform/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  // app.useGlobalInterceptors(new TransformInterceptor());
  await app.listen(3000);
}
bootstrap();
ts 复制代码
// app.module.ts

...
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { TransformInterceptor } from './global/interceptor/transform/transform.interceptor';

@Module({
  ...
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
    {
      provide: APP_INTERCEPTOR,						// 在这里注册
      useClass: TransformInterceptor,
    },
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

测试一下:

重新请求了登录接口,查看 log:

json 复制代码
{"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}
{"level":"info","message":"route","req":{"body":{},"ip":"::1","method":"GET","params":{"0":"user/1"},"query":{},"url":"/user/1"}}
{"level":"error","message":"token已过期","req":{"body":{},"ip":"::1","method":"GET","params":{"id":"1"},"query":{},"url":"/user/1"},"status":401}
{"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}
{"level":"info","message":"response","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{},"query":{},"url":"/auth/login"},"responseData":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IndhbmciLCJpZCI6MTQsImlhdCI6MTY5MDg3MTE2Nn0.MT5H1Izgh4L9PleNhzfQsVYlVmPcQkxTCpKDnnO2i-s","type":"Bearer"}}

可以看到这次请求的信息和响应的信息都符合预期的记录在了文件中。

美化 log

现在我们已经可以将一些重要日志记录在本地文件中,但是查看的时候很费劲,一坨一坨的数据辣眼睛,因此笔者决定对日志进行格式化,方便查看。

ts 复制代码
// app.module.ts

...
import { format, transports } from 'winston';

@Module({
  imports: [
    ...
    WinstonModule.forRoot({
      transports: [
        new transports.DailyRotateFile({
          dirname: `logs`,
          filename: `%DATE%.log`,
          datePattern: 'YYYY-MM-DD',
          zippedArchive: true,
          maxSize: '20m',
          maxFiles: '14d',
          format: format.combine(
            format.timestamp({
              format: 'YYYY-MM-DD HH:mm:ss',
            }),
            format.printf(
              (info) =>
                `${info.timestamp} [${info.level}] : ${info.message} ${
                  Object.keys(info).length ? JSON.stringify(info, null, 2) : ''
                }`,
            ),
          ),
        }),
      ],
    }),
  ],
  ...
})

...

winston在配置时可以接受一个format参数,自定义格式,通过winston包中自带的format可以进行很多操作,这里笔者就不展开了,大家可以根据自己的喜好,查阅文档,进行自定义。

查看一下效果:

json 复制代码
{"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}
{"level":"info","message":"route","req":{"body":{},"ip":"::1","method":"GET","params":{"0":"user/1"},"query":{},"url":"/user/1"}}
{"level":"error","message":"token已过期","req":{"body":{},"ip":"::1","method":"GET","params":{"id":"1"},"query":{},"url":"/user/1"},"status":401}
{"level":"info","message":"route","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{"0":"auth/login"},"query":{},"url":"/auth/login"}}
{"level":"info","message":"response","req":{"body":{"password":"123456","username":"wang"},"ip":"::1","method":"POST","params":{},"query":{},"url":"/auth/login"},"responseData":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IndhbmciLCJpZCI6MTQsImlhdCI6MTY5MDg3MTE2Nn0.MT5H1Izgh4L9PleNhzfQsVYlVmPcQkxTCpKDnnO2i-s","type":"Bearer"}}
2023-08-01 14:34:43 [info] : route {
  "req": {
    "method": "POST",
    "url": "/auth/login",
    "body": {
      "username": "wang",
      "password": "123456"
    },
    "query": {},
    "params": {
      "0": "auth/login"
    },
    "ip": "::1"
  },
  "level": "info",
  "message": "route",
  "timestamp": "2023-08-01 14:34:43"
}
2023-08-01 14:34:43 [info] : response {
  "responseData": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IndhbmciLCJpZCI6MTQsImlhdCI6MTY5MDg3MTY4M30.n-17Fz3On6n1UfOKfRMFzBbXfzbfNQt_oiC-1WXzhB4",
    "type": "Bearer"
  },
  "req": {
    "method": "POST",
    "url": "/auth/login",
    "body": {
      "username": "wang",
      "password": "123456"
    },
    "query": {},
    "params": {},
    "ip": "::1"
  },
  "level": "info",
  "message": "response",
  "timestamp": "2023-08-01 14:34:43"
}

这样美化有利有弊,大家可以根据实际情况酌情选择。

后记

本系列文章到这里算是完结了,不论是 NestJS 还是本系列文章中提到的各种工具,都有着非常多的功能,笔者也只是一个初学者,也只是浅尝辄止的进行了学习,并分享给大家,后面笔者也会继续分享其他内容。如果有可以改进的地方,欢迎和我交流,如果有错误,还请大家斧正。

Nest学习系列博客代码仓库 (github.com)

冷面杀手的个人站 (bald3r.wang)

NestJS 相关文章

相关推荐
红尘散仙1 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记3 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆3 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪3 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6164 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_2518364574 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao4 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒5 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
卷帘依旧6 小时前
v8引擎和libuv的关系
node.js
ayqy贾杰6 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理