NestJS 项目实战-权限管理系统开发(二)

546 阅读6分钟

本系列教程将教你使用 NestJS 构建一个生产级别的 REST API 风格的权限管理后台服务【代码仓库地址】。

在线预览地址】账号:test,密码:d.12345

本章节内容: 1. 环境变量的加载与管理;2. 注册 Prisma 服务;3. 编写 Swagger API 文档;4. 使用 Winston 管理日志。

1. 环境变量的加载与管理

1.1 加载环境变量

NestJS 提供了一个开箱即用的 @nestjs/config 包来加载环境文件,在终端里运行以下命令安装:

pnpm add @nestjs/config

安装完成后,在 app.module.ts 中导入 ConfigModule ,代码如下:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

// 新增代码
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    // 新增代码
    ConfigModule.forRoot({
      envFilePath: process.env.NODE_ENV === 'production'
          ? '../.env.production.local'
          : `../.env.${process.env.NODE_ENV || 'development'}`,
      isGlobal: true,
      cache: true,
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

上面的代码将从项目根目录加载与解析对应环境的 .env 文件。isGlobal 设置为 true 表示这是一个全局模块,cache 设置为 true 表示缓存解析结果(环境变量文件更改后需要重启项目才能加载新的环境变量)。

接下来我们来测试一下。首先在 .env.development 文件中添加以下变量:

# common
PORT=3000

然后修改 main.ts 文件:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// 新增代码
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 新增代码
  const PORT = +app.get(ConfigService).get<number>('PORT');
  await app.listen(PORT);
}
bootstrap();

以上新增代码部分,从 ConfigService 中读取了 PORT 变量的值,并设置为了 NestJS 服务的端口号。

我们可以运行以下命令测试服务:

pnpm run start:dev

注意:package.json 中的 start:dev 命令的实际内容要修改为 dotenv -e .env.development -- nest start --watch 哦。

可以看到服务正常启动了。打印一下 PORT 变量的值,会发现是我们在 .env.development 中设置的 3000

1.2 统一管理环境变量

通过 +app.get(ConfigService).get<number>('PORT') 虽然可以获取变量的值,但如果需要在多个地方用相同变量的话,那都需要写一遍 xxx.get<xxx>('xxx') ,这样每次都要写一遍变量的名字,可能存在名称写错的情况,还可能存在没有转换类型的情况。

如果能在一个文件里获取所有变量并转换为 js 对象就好了。

这是可以的。首先,创建一个 /src/common/config/index.ts 文件,并添加以下内容:

import { ConfigService } from '@nestjs/config';

export const getBaseConfig = (configService: ConfigService) => ({
  env: configService.get<string>('NODE_ENV'),
  port: +configService.get<number>('PORT', 3000),
  bcryptSaltRounds: +configService.get<number>('BCRYPT_SALT_ROUNDS', 10),
  defaultAdmin: {
    username: configService.get<string>('DEFAULT_ADMIN_USERNAME'),
    password: configService.get<string>('DEFAULT_ADMIN_PASSWORD'),
    permission: configService.get<string>('DEFAULT_ADMIN_PERMISSION'),
    role: configService.get<string>('DEFAULT_ADMIN_ROLE'),
  },
  db: {
    url: configService.get<string>('DATABASE_URL'),
  },
});

然后,修改 main.ts 的内容:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
// 新增代码
import { getBaseConfig } from './common/config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 变化部分
  const config = getBaseConfig(app.get(ConfigService));
  const PORT = config.port;
  
  await app.listen(PORT);
}
bootstrap();

再运行服务测试一下,可以正常启动,OK。

2. 注册 Prisma 服务

NestJS 也为我们提供了一个开箱即用的 nestjs-prisma 包,我们可以使用这个包便捷地注册 prisma 服务。运行以下命令安装包:

pnpm add nestjs-prisma

然后在 app.module.ts 中添加以下内容:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
// 新增部分
import { PrismaModule } from 'nestjs-prisma';
import { getBaseConfig } from './common/config';

@Module({
  imports: [
    ...
    // 新增部分
    PrismaModule.forRootAsync({
      isGlobal: true,
      useFactory: (configService: ConfigService) => {
        return {
          prismaOptions: {
            datasources: {
              db: {
                url: getBaseConfig(configService).db.url,
              },
            },
          },
          explicitConnect: false,
        };
      },
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

我们来分析一下新增代码:

  1. PrismaModule.forRootAsync 是一个异步配置方法;
  2. isGlobal 设置为 true 代表这是一个全局模块;
  3. useFactory 是一个工厂函数,用于动态创建配置;
  4. explicitConnect 配置设为 false 表示 Prisma 客户端会自动连接到数据库,而不需要显式调用 connect 方法;
  5. inject: [ConfigService] 指定需要注入到工厂函数中的依赖。这里注入了 ConfigService

至此,我们已经成功创建了 Prisma 服务,但目前还不会用到,后续章节才会用到这个服务。

3. 编写 Swagger API 文档

Swagger 是一种使用 OpenAPI 规范记录 API 的工具。NestJS 为我们提供了专用包,运行以下命令安装:

pnpm add @nestjs/swagger swagger-ui-express

现在打开 .env.development 文件,添加以下变量:

# swagger
SWAGGER_TITLE="API Documentation"
SWAGGER_DESCRIPTION="RESTful API"
SWAGGER_VERSION="1.0.0"

然后打开 /src/common/config/index.ts 文件,添加以下内容到返回对象中:

swagger: {
    title: configService.get<string>('SWAGGER_TITLE'),
    description: configService.get<string>('SWAGGER_DESCRIPTION'),
    version: configService.get<string>('SWAGGER_VERSION')
}

最后在 main.ts 中使用 SwaggerModule 类初始化 Swagger:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { getBaseConfig } from './common/config';
// 新增部分
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = getBaseConfig(app.get(ConfigService));
  // 新增部分
  const title = config.swagger.title;
  const description = config.swagger.description;
  const version = config.swagger.version;
  const swaggerConfig = new DocumentBuilder()
    .setTitle(title)
    .setDescription(description)
    .setVersion(version)
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, swaggerConfig);
  SwaggerModule.setup('api', app, document);

  const PORT = config.port;
  await app.listen(PORT);
}
bootstrap();

运行程序,打开浏览器并导航到 http://localhost:3000/api 。您应该会看到 Swagger API 接口文档。

4. 使用 Winston 管理日志

Winston 是一个流行的 Node.js 日志库。本系统也将使用该库来记录日志。

首先,运行以下命令安装所需的库:

pnpm add winston nest-winston winston-daily-rotate-file

然后在 .env.development 文件中添加以下内容:

# winston
LOG_LEVEL="debug"
LOG_DIR="./logs"
LOG_MAX_SIZE="2m"
LOG_MAX_FILES="5d"
LOG_DATE_PATTERN="YYYY-MM-DD"

也要在 /src/common/config/index.ts 中添加相应变量:

winston: {
    logLevel: configService.get<string>('LOG_LEVEL'),
    logDir: configService.get<string>('LOG_DIR'),
    logMaxSize: configService.get<string>('LOG_MAX_SIZE'),
    logMaxFiles: configService.get<string>('LOG_MAX_FILES'),
    logDatePattern: configService.get<string>('LOG_DATE_PATTERN'),
},

打开 app.module.ts 先在头部导入:

import { WinstonModule, utilities } from 'nest-winston';
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import path from 'path';

然后添加WinstonModule模块到imports数组:

WinstonModule.forRootAsync({
      useFactory: (configService: ConfigService) => {
        const config = getBaseConfig(configService);
        const logLevel = config.winston.logLevel;
        const logDir = config.winston.logDir;
        const maxSize = config.winston.logMaxSize;
        const maxFiles = config.winston.logMaxFiles;
        const datePattern = config.winston.logDatePattern;

        return {
          level: logLevel,
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.ms(),
            utilities.format.nestLike('MyApp', {
              prettyPrint: true,
              colors: true,
            }),
            winston.format.errors({ stack: true }),
          ),
          transports: [
            new winston.transports.Console({
              format: winston.format.combine(
                winston.format.timestamp(),
                utilities.format.nestLike(),
              ),
            }),
            new DailyRotateFile({
              dirname: path.join(logDir, logLevel),
              filename: 'application-%DATE%.log',
              datePattern,
              zippedArchive: true,
              maxSize,
              maxFiles,
              level: logLevel,
            }),
            new DailyRotateFile({
              dirname: path.join(logDir, 'error'),
              filename: 'error-%DATE%.log',
              datePattern,
              zippedArchive: true,
              maxSize,
              maxFiles,
              level: 'error',
            }),
          ],
          exceptionHandlers: [
            new DailyRotateFile({
              dirname: path.join(logDir, 'exceptions'),
              filename: 'exceptions-%DATE%.log',
              datePattern,
              zippedArchive: true,
              maxSize,
              maxFiles,
            }),
          ],
        };
      },
      inject: [ConfigService],
    }),

以上配置主要实现了:

  1. 分类存储:普通日志、错误日志和异常日志分别存储;
  2. 轮换策略:设置了基于日期、文件大小的日志轮换,防止日志文件过大;
  3. 格式化:使用 NestJS 风格的格式,提高可读性;
  4. 多目标输出:同时输出到控制台和文件,方便开发和问题追踪。

现在,在 main.ts 的头部添加导入语句:

import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

然后在bootstrap()方法中添加以下代码:

const logger = app.get(WINSTON_MODULE_NEST_PROVIDER);
app.useLogger(logger);

最后启动应用,会发现无法正常启动或报错。这是 import winston from 'winston'; 这条导入语句的问题,因为 winstoncommonjs 模块且没有默认导出。

修改导入语句或 tsconfig.json 的配置可以解决这个问题。

打开 tsconfig.json 文件,在 compilerOptions 中添加一条 "esModuleInterop": true

现在运行应用,可以看到控制台打印了 NestWinston 开头的日志了,也创建了 logs 文件夹与相应的日志文件。

下一章节见~