05-nestjs基础实践,输出日志,异常拦截,数据库代码重构

536 阅读8分钟

nestjs日志

目前方案:

  • 内置日志模块(平常开发用)
  • pino(简单)
  • winston(用于生产)

日志等级

  • Log: 通用日志,按需记录
  • Warning: 警告日志,如多次对数据库进行操作
  • Error: 严重日志,如数据库异常
  • Debug: 调试日志,如加载数据库日志
  • Verbose: 详细日志,所有操作和详细信息,非必要不打印

功能分类

  • 错误日志:方便定位问题,给用户友好提示
  • 调试日志,方便开发
  • 请求日志,记录敏感行为

日志记录位置

  • 控制台日志:方便调试用
  • 文件日志:方便回溯和追踪(24消失滚动)
  • 数据库日志:敏感操作、敏感数据

image.png

内置日志

在全局配置main.ts

async function bootstrap() {
  const logger = new Logger()
  const app = await NestFactory.create(AppModule, {
    // logger的默认配置是true, 默认全部打印
    // logger: false, // 关闭整个应用程序的日志
    // logger: ['error', 'warn'], // 植打印'error'和'warn'等级的日志
  })
  app.setGlobalPrefix('api') // 给每一个接口添加前缀'/api'
  await app.listen(3000)
  logger.log(`App 运行在3000端口`) // 这个warn的日志会被打印出来
  logger.warn(`App 运行在3000端口`) // 这个warn的日志会被打印出来
  logger.error(`App 运行在3000端口`) // 这个warn的日志会被打印出来
}

image.png

在控制器user.controller.ts中打印日志

import { Controller, Get, Logger } from '@nestjs/common'
import { UserService } from './user.service'

@Controller('user')
export class UserController {
  private logger = new Logger(UserController.name)
  constructor(private userService: UserService) {
    this.logger.log('------UserController init')
  }
  @Get()
  getUsers() {
    this.logger.log('------请求用户列表成功')
    return {}
  }
}

打印如下 image.png image.png

第三方日志pino

安装插件npm install nestjs-pino文档地址pino用法

import { NestFactory } from '@nestjs/core'
import { Controller, Get, Module } from '@nestjs/common'
import { LoggerModule, Logger } from 'nestjs-pino'

@Module({
  controllers: [AppController],
  imports: [LoggerModule.forRoot()]
})
class MyModule {}

@Controller()
export class AppController {
  constructor(private readonly logger: Logger) {
    this.logger.log('------UserController init')
  }
  @Get()
  getHello() {
    this.logger.log('something')
    return `Hello world`
  }
}
async function bootstrap() {
  const app = await NestFactory.create(MyModule)
  await app.listen(3000)
}
bootstrap()

打印如下(请求接口的时候会自动打印请求信息) image.png image.png

安装中间件,

  • 美化打印npm i pino-pretty, 用于开发
  • 保存日志到文件的中间件npm i pino-roll,用于生产

在根模块app.module.ts中配置

@Module({
  controllers: [AppController],
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        transport:
          process.env.NODE_ENV === 'development'
            ? {
                target: 'pino-pretty',
                options: { colorize: true },
              }
            : {
                target: 'pino-roll',
                options: {
                  file: join('log', 'log.txt'), // 会输出文件到指定路径
                  frequency: 'daily', // hourly
                  size: '10m', // 文件大小,超过10m就会生成新的文件
                  mkdir: true,
                },
              },
      },
    }),
  ]
})

第三方日志winston

安装插件npm install -S nest-winston winstonwinston用法nest-winston用法

在入口main.ts中引入wiston模块

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
+ import { createLogger } from 'winston'
+ import * as winston from 'winston'
+ import { WinstonModule, utilities } from 'nest-winston'
async function bootstrap() {
  + const instance = createLogger({
  + transports: [
  +    new winston.transports.Console({
  +       level: 'info',
  +       format: winston.format.combine(winston.format.timestamp(), 
  +       utilities.format.nestLike()),
  +     }),
  +   ],
  + })
  + const logger = WinstonModule.createLogger({ instance })
  + const app = await NestFactory.create(AppModule, { logger })
  await app.listen(3000)
}
bootstrap()

app.module.ts中注入全局服务

import { Logger,  } from '@nestjs/common'
@Global()
@Module({
  providers: [Logger],
  exports: [Logger],
})

在控制器中使用

import { Controller, Get, Logger} from '@nestjs/common'
import { UserService } from './user.service'

@Controller('user')
export class UserController {
  constructor(
    private userService: UserService,
    private readonly logger: Logger, // private logger: Logger,
  ) {
    this.logger.log('UserController 初始化')
  }

  @Get()
  getUsers() {
    this.logger.log('请求用户列表-ok')
    this.logger.warn('请求用户列表-ok')
    this.logger.error('请求用户列表-ok')
    return {}
  }
}

image.png

滚动日志,安装插件npm i winston-daily-rotate-file 在入口main.ts中修改代码

import 'winston-daily-rotate-file'
async function bootstrap() {
  const instance = createLogger({
    // options of Winston
    transports: [
      new winston.transports.Console({
        level: 'info', // 打印所有info、warn、error的信息
        format: winston.format.combine(winston.format.timestamp(), 
        utilities.format.nestLike()),
      }),
      + new winston.transports.DailyRotateFile({
      +   level: 'warn', // 只打印warn、error信息
      +   dirname: 'log', // 输出目录
      +   filename: 'application-%DATE%.log', // 文件命名
      +   datePattern: 'YYYY-MM-DD-HH',
      +   zippedArchive: true,
      +   maxSize: '20m',
      +   maxFiles: '14d',
      +   format: winston.format.combine(winston.format.timestamp(), winston.format.simple()),
      + }),
      + new winston.transports.DailyRotateFile({
      +   level: 'info', // 打印所有info、warn、error的信息
      +   dirname: 'log', // 输出目录
      +   filename: 'info-%DATE%.log', // 文件命名
      +   datePattern: 'YYYY-MM-DD-HH',
      +   zippedArchive: true,
      +   maxSize: '20m',
      +   maxFiles: '14d',
      +   format: winston.format.combine(winston.format.timestamp(), winston.format.simple()),
      + }),
    ],
  })
  const logger = WinstonModule.createLogger({ instance })
  const app = await NestFactory.create(AppModule, { logger })
  await app.listen(3000)
}
bootstrap()

全局过滤器输出日志

拦截http异常并输出到日志

创建http过滤器./filters/http-exception.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, LoggerService } from '@nestjs/common'
import { Request, Response } from 'express'

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  constructor(private logger: LoggerService) {}
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const req: Request = ctx.getRequest() // 请求对象
    const res: Response = ctx.getResponse() // 响应对象
    const status = exception.getStatus() // http状态码
    // 打印日志
    this.logger.error(exception.message, exception.stack)
    // 返回接口
    res.status(status).json({
      code: status,
      // timestamp: new Date().toDateString(),
      // path: req.url,
      // method: req.method,
      msg: exception.message || HttpException.name,
    })
  }
}

main.ts中引入

import { HttpExceptionFilter } from './filters/http-exception.filter'
async function bootstrap() {
  const logger = WinstonModule.createLogger({ instance })
  const app = await NestFactory.create(AppModule, { logger })
  app.useGlobalFilters(new HttpExceptionFilter(logger))
}

拦截全局异常并输出到日志

创建http过滤器./filters/all-exception.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, LoggerService } from '@nestjs/common'
import { HttpAdapterHost } from '@nestjs/core'
import { Request, Response } from 'express'
import * as requestIp from 'request-ip'

@Catch()
export class AllExceptionFilter implements ExceptionFilter {
  constructor(
    private logger: LoggerService,
    private httpAdapterHost: HttpAdapterHost,
  ) {}
  catch(exception: unknown, host: ArgumentsHost) {
    const { httpAdapter } = this.httpAdapterHost
    const ctx = host.switchToHttp()
    const req: Request = ctx.getRequest() // 请求对象
    const res: Response = ctx.getResponse() // 响应对象
    // http状态码
    const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
    const resBody = {
      Headers: req.headers,
      query: req.query,
      body: req.body,
      params: req.params,
      timestamp: new Date().toISOString(),
      ip: requestIp.getClientIp(req), // 需要安装`request-ip`插件
      exception: exception['name'],
      error: exception['response'] || 'Internal Server Error',
    }
    // 打印日志
    this.logger.error('[toimic]', resBody)
    // 返回接口 
    const resData = { 
      code: status, msg: resBody.error, 
      // timestamp: new Date().toDateString(), 
    } 
    httpAdapter.reply(response, resData, status)
  }
}

main.ts中引入

import { AllExceptionFilter } from './filters/all-exception.filter'
async function bootstrap() {
  ...
  const logger = WinstonModule.createLogger({ instance })
  const app = await NestFactory.create(AppModule, { logger })
  const httpAdapter = app.get(HttpAdapterHost)
  app.useGlobalFilters(new AllExceptionFilter(logger, httpAdapter))
  await app.listen(3000)
}

日志模块使用优化

利用nestcli生成一个logs模块:nest g mo logs

.env文件中中增肌配置

# logs日志配置
LOG_ON=true
LOG_LEVEL=info

src/enum/config.enum.ts中增加配置

/** log日志枚举 */
export enum LogEnum {
  LOG_ON = 'LOG_ON',
  LOG_LEVEL = 'LOG_LEVEL',
}

创建src/logs/logs.module.ts文件

import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { WinstonModule, WinstonModuleOptions, utilities } from 'nest-winston'
import * as winston from 'winston'
import { Console } from 'winston/lib/winston/transports'
import { LogEnum } from 'src/enum/config.enum'
const DailyRotateFile = require('winston-daily-rotate-file')
@Module({
  imports: [
    WinstonModule.forRootAsync({
      inject: [ConfigService],
      useFactory(configService: ConfigService) {
        const consoleTransport = new Console({
          level: 'info', // 打印所有info、warn、error的信息
          format: winston.format.combine(winston.format.timestamp(), utilities.format.nestLike()),
        })
        // 下面2个是会输出文件的
        const dailyTransport = new DailyRotateFile({
          level: 'warn', // 只打印warn、error信息
          filename: 'application-%DATE%.log', // 文件命名
          dirname: 'log', // 输出目录
          datePattern: 'YYYY-MM-DD-HH',
          zippedArchive: true,
          maxSize: '20m',
          maxFiles: '14d',
          format: winston.format.combine(winston.format.timestamp(), winston.format.simple()),
        })
        const dailyInfoTransport = new DailyRotateFile({
          level: configService.get(LogEnum.LOG_LEVEL), // 打印所有info、warn、error的信息
          filename: 'info-%DATE%.log', // 文件命名
          dirname: 'log', // 输出目录
          datePattern: 'YYYY-MM-DD-HH',
          zippedArchive: true,
          maxSize: '20m',
          maxFiles: '14d',
          format: winston.format.combine(winston.format.timestamp(), winston.format.simple()),
        })
        return {
          transports: [consoleTransport, ...(configService.get(LogEnum.LOG_ON) ? [dailyTransport, dailyInfoTransport] : [])],
        } as WinstonModuleOptions
      },
    }),
  ],
})
export class LogsModule {}

main.ts中引入文件

import { HttpAdapterHost, NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'
import { AllExceptionFilter } from './filters/all-exception.filter'

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {})
  + const logger = app.get(WINSTON_MODULE_NEST_PROVIDER)
  + app.useLogger(logger)
  // 把异常写入日志文件
  app.useGlobalFilters(new AllExceptionFilter(logger, app.get(HttpAdapterHost)))
  await app.listen(3000)
}
bootstrap()

app.module.ts中引入模块

import { Global, Logger, Module } from '@nestjs/common'
import { LogsModule } from './logs/logs.module'
@Global()
@Module({
  imports: [
    ...
    LogsModule
  ],
  providers: [Logger],
  exports: [Logger],
})

src/user/user.module.ts中使用日志模块

import { Controller, Get, LoggerService } from '@nestjs/common'
+ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'
@Controller('user')
export class UserController {
  constructor(
    private userService: UserService,
    + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService,
  ) {
    + this.logger.log('UserController初始化')
  }
}

typeorm cli数据库代码重构

提取typeorm配置

typeorm 文档

全局安装 ts-node:npm install ts-node --save-dev

在 package.json 中的 scripts 下添加 typeorm 命令

"script" {
    ...
    "typeorm": "typeorm-ts-node-commonjs"
}

创建文件ormconfig.ts

import { TypeOrmModuleOptions } from '@nestjs/typeorm'
import { Logs } from 'src/logs/logs.entity'
import { Roles } from 'src/roles/roles.entity'
import { Profile } from 'src/user/profile.entity'
import { User } from 'src/user/user.entity'
export default {
  type: 'mysql',
  host: '11.111.111.111',
  port: 3306,
  username: 'root',
  password: 'you_mysql_password',
  database: 'testdb',
  // 定义数据库表结构与实体类字段同步(这里一旦数据库少了字段就会自动加入,根据需要来使用), 一般用于数据库初始化
  synchronize: true,
  // 扫描本项目中.entity.ts或者.entity.js的文件: [__dirname + '/**/*.entity{.ts,.js}']
  entities: [User, Profile, Logs, Roles],
  // logging: process.env.NODE_ENV === 'development' ? true : ['error'], // 开发环境打印所有日志
  logging: false, // 开发环境打印所有日志
} as TypeOrmModuleOptions

修改app.module.ts

@Module({
  imports: [
    ...
    + TypeOrmModule.forRoot(ormconfig),
  ],
})

TypeORM项目

初始化一个新的TypeORM项目: npx typeorm init --database mysql2

上面的步骤会生成一些文件./src/data-source.ts,./src/entity/User.ts,'./src/index.ts'

参考data-source.ts修改文件ormconfig.ts, 删除自动生成的data-source.tsUser.ts文件

import { TypeOrmModuleOptions } from '@nestjs/typeorm'
import { Logs } from './src/logs/logs.entity'
import { Roles } from './src/roles/roles.entity'
import { Profile } from './src/user/profile.entity'
import { User } from './src/user/user.entity'
import { DataSource, DataSourceOptions } from 'typeorm'
export const connectionParams = {
  type: 'mysql',
  host: '11.111.111.111',
  port: 3306,
  username: 'root',
  password: 'you_mysql_password',
  database: 'testdb',
  // 定义数据库表结构与实体类字段同步(这里一旦数据库少了字段就会自动加入,根据需要来使用), 一般用于数据库初始化
  synchronize: true,
  // 扫描本项目中.entity.ts或者.entity.js的文件: [__dirname + '/**/*.entity{.ts,.js}']
  entities: [User, Profile, Logs, Roles],
  // logging: process.env.NODE_ENV === 'development' ? true : ['error'], // 开发环境打印所有日志
  logging: false, // 开发环境打印所有日志
} as TypeOrmModuleOptions

export default new DataSource({
  ...connectionParams,
  migrations: ['src/migrations/**'],
  subscribers: [],
} as DataSourceOptions)

修改app.module.ts

import { connectionParams } from '../ormconfig'
@Module({
  imports: [
    ...
    + TypeOrmModule.forRoot(connectionParams),
  ],
})

编辑自动生成./src/index.ts测试, 运行npx ts-node .\src\index.ts测试数据库后删除此文件

import AppDataSource from '../ormconfig'
import { User } from './user/user.entity'
AppDataSource.initialize()
  .then(async () => {
    const res = await AppDataSource.manager.find(User)
    console.log('Here you can setup and run express / fastify / any other framework.', res)
  })
  .catch((error) => console.log(error))

image.png

执行npm run start:dev运行项目,成功

读取不同的环境变量

修改文件ormconfig.ts

import { TypeOrmModuleOptions } from '@nestjs/typeorm'
import { DataSource, DataSourceOptions } from 'typeorm'
import * as fs from 'fs'
import * as dotenv from 'dotenv'
import { ConfigEnum } from './src/enum/config.enum'

// 通过环境变量读取不同的.env文件
function getEnv(env: string): Record<string, unknown> {
  if (fs.existsSync(env)) {
    return dotenv.parse(fs.readFileSync(env))
  }
  return {}
}
// 通过dotenv解析不同的配置
function buildConnectionOptions() {
  const defaultConfig = getEnv('.env')
  const envConfig = getEnv(`.env.${process.env.NODE_ENV || 'development'}`)
  const config = { ...defaultConfig, envConfig } // 合并配置
  const entitiesDir = process.env.NODE_ENV === 'development' ? [__dirname + '/**/*.entity.ts'] : [__dirname + '/**/*.entity{.ts,.js}']
  return {
    type: config[ConfigEnum.DB_TYPE],
    host: config[ConfigEnum.DB_HOST],
    port: config[ConfigEnum.DB_PORT],
    username: config[ConfigEnum.DB_USERNAME],
    password: config[ConfigEnum.DB_PASSWORD],
    database: config[ConfigEnum.DB_DATABASE],
    // 扫描本项目中.entity.ts或者.entity.js的文件: [__dirname + '/**/*.entity{.ts,.js}']
    entities: entitiesDir,
    // 定义数据库表结构与实体类字段同步(这里一旦数据库少了字段就会自动加入,根据需要来使用), 一般用于数据库初始化
    synchronize: true,
    // logging: process.env.NODE_ENV === 'development' ? true : ['error'], // 开发环境打印所有日志
    logging: false, // 开发环境打印所有日志
  } as TypeOrmModuleOptions
}

export const connectionParams = buildConnectionOptions()

export default new DataSource({
  ...connectionParams,
  migrations: ['src/migrations/**'],
  subscribers: [],
} as DataSourceOptions)

修改package.json中的打包命令

"scripts": {
  "build": "cross-env NODE_ENV=production nest build",
}

执行项目打包npm run build,我们发现打包目录变成了build.解决方案是删除因为typeorm项目初始化生成的tsconfig.json配置,恢复为最原始的默认配置

执行npm run start:prod运行生产环境,又报错了: Error: Cannot find module 'D:\demo\movie-cms\01-nestjs-demo\dist\main'.

解决方案, 修改package.json中的命令:node dist/main->node dist/src/main

"scripts": {
  "start:prod": "cross-env NODE_ENV=production node dist/src/main",
}

现在存在一个问题, 无论怎么配置LOG_ON环境变量都会生成log日志文件夹, 编辑logs.module.ts

import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { WinstonModule, WinstonModuleOptions, utilities } from 'nest-winston'
import * as winston from 'winston'
import { Console } from 'winston/lib/winston/transports'
import { LogEnum } from '../enum/config.enum'
const DailyRotateFile = require('winston-daily-rotate-file')
function createTransport(level: string, filename: string) {
  return new DailyRotateFile({
    level, // 只打印什么级别的信息, info、warn、error信息
    filename: `${filename}-%DATE%.log`, // 文件命名
    dirname: 'log', // 输出目录
    datePattern: 'YYYY-MM-DD-HH',
    zippedArchive: true,
    maxSize: '20m',
    maxFiles: '14d',
    format: winston.format.combine(winston.format.timestamp(), winston.format.simple()),
  })
}
@Module({
  imports: [
    WinstonModule.forRootAsync({
      inject: [ConfigService],
      useFactory(configService: ConfigService) {
        const consoleTransport = new Console({
          level: 'info', // 打印所有info、warn、error的信息
          format: winston.format.combine(winston.format.timestamp(), utilities.format.nestLike()),
        })
        const LOG_ON = configService.get(LogEnum.LOG_ON) === 'true'
        const writeTransport = LOG_ON ? [createTransport('info', 'application'), createTransport('warn', 'error')] : []
        return {
          transports: [consoleTransport, ...writeTransport],
        } as WinstonModuleOptions
      },
    }),
  ],
})
export class LogsModule {}

添加命令

package.json中添加命令, 以后生产中用于数据库迁移

"scripts": {
    "generate:models": "typeorm-model-generator -h 11.111.111.111 -p 3306 -d mysqlDbName -u root -x mysqlPassword -e mysql -o ./src/entites",
    "typeorm": "typeorm-ts-node-commonjs -d ormconfig.ts",
    "migration:generate": "f() { npm run typeorm migration:generate -p \"./src/migrations/$@\";}; f",
    "migration:create": "typeorm-ts-node-commonjs migration:create",
    "migration:run": "npm run typeorm migration:run",
    "migration:revert": "npm run typeorm migration:revert",
    "migration:drop": "npm run typeorm schema:drop"
}

数据库结构Entity变化迁移步骤

以下在执行命令时未设置环境变量,在实际过程中请设置正确环境变量,否则修改的数据库地址可能不对

第一步,执行migration:create命令, 记录当前的版本

# 会创建一个文件`src/migrations/init/timestamp-init.ts`
$ npm run migration:create src/migrations/init

第二步,按需新建和修改Entity文件

创建文件src/menus/menus.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
enum TypeEnum {MENU = 'menu', PAGE = 'page', AUTH = 'auth',}
@Entity()
export class Menus {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ comment: '名称' })
  name: string

  @Column({ comment: '路径', default: '' })
  path: string // 路径

  @Column({ comment: '排序' })
  order: number // 排序

  @Column({ comment: '图标,"menu", "page"有图标,"auth"没有图标', default: '' })
  icon: string // 图标

  @Column({ comment: '菜单类型', type: 'enum', enum: TypeEnum, default: TypeEnum.MENU })
  type: TypeEnum

  @Column({ comment: '父级id', default: 0 })
  pid: number // 父级id

  @Column({ comment: '是否在菜单栏显示', default: true })
  inMenu: boolean // 是否在菜单栏显示
}

修改文件src/roles/roles.entity.ts

import { User } from '../user/user.entity'
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'

@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  name: string

  @Column({ type: 'longtext' })
  auths: string // 保存menu的id的数组

  @ManyToMany(() => User, (user) => user.roles)
  users: User[]
}

ormconfig.ts配置文件中引入刚刚创建的src/menus/menus.entity.ts

+ import { Menus } from './src/menus/menus.entity'
...
+ entities: [User, Logs, Profile, Roles, Menus],
...

第三步,执行migration:generate命令, 记录当前的版本对数据库的操作命令

会创建一个文件src/migrations/init/timestamp-menus.ts

$ npm run typeorm migration:generate -p "./src/migrations/menus"

image.png

第四步,修改真实的数据库结构

当前表列表和角色表

image.png

执行命令对真实数据库进迁移: npm run typeorm migration:run, 再次查看表结构

image.png

如果线上数据库出现异常情况,执行命令退回上一个版本

# 执行完这条命令后,线上数据库又回到了之前未修改的状态
# 这条命令只能回退一个版本,要回退多个版本需要执行多次
$ npm run typeorm migration:revert