nestjs 基本使用

工具安装

node 版本管理工具

nvmnode 版本管理工具

安装 nvm

bash 复制代码
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

安装完 nvm 之后将下面这段写入 ~/.profile 文件中,然后重启终端或者 source ~/.profile

bash 复制代码
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

使用:

bash 复制代码
nvm ls-remote # 查看所有可用版本
nvm install v14.18.1 # 安装指定版本
nvm uninstall v14.18.1 # 卸载指定版本
nvm use v14.18.1 # 使用指定版本
nvm ls # 查看已安装版本
nvm alias node@14 v14.18.1 # 设置别名

npm 源管理工具

nrmnpm 源管理工具

安装 nrm

bash 复制代码
pnpm add -g nrm

nrm 使用

bash 复制代码
nrm add xxx http://xxx # 添加源
nrm ls # 查看源
nrm use xxx # 使用源

docker

运行 docker-compose up -d 启动

yaml 复制代码
version: "3.1"

services:
  db:
    image: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example # 设置 root 用户的密码
    ports:
      - 3090:3306 # 映射到宿主机的端口:容器内部的端口

  adminer:
    image: adminer
    restart: always
    ports:
      - 8090:8080

通过 docker-compose 创建的容器,他们是处于同一个网段的

可以通过 docker inspect <container_name> 查看容器信息,其中 Networks 字段就是容器的网络信息

bash 复制代码
 Networks: {
  nestjs_default: { # 网络名称
    IPAMConfig: null,
    Links: null,
    Aliases: ["nestjs-adminer-1", "adminer", "7ac06954919d"],
    NetworkID: "c5cf9bbe26800889a449708bb027e009409b43abc0e1f2090ed7d8d7a57d45ca",
    EndpointID: "776561fc5ef781c3a2fa3a468c46a352bed24a000adb89c1d8ed848158a0a9c5",
    Gateway: "172.20.0.1",
    IPAddress: "172.20.0.3", # 容器的 ip 地址
    IPPrefixLen: 16,
    IPv6Gateway: "",
    GlobalIPv6Address: "",
    GlobalIPv6PrefixLen: 0,
    MacAddress: "02:42:ac:14:00:03",
    DriverOpts: null,
  },
}

网络名称可以通过 docker network ls 查看

bash 复制代码
22f3062e96ae   bridge           bridge    local
e4ef76612531   ghost_ghost      bridge    local
f4afa56878b3   host             host      local
c5cf9bbe2680   nestjs_default   bridge    local   # 这个就是上面的网络名称
66ccce26b0ed   network1         bridge    local
e5f933620687   none             null      local
74520c9f00b4   wxcb0            bridge    local

docker inspect <network_name> 网络信息,其中 Containers 字段就是该网络下的容器信息

bash 复制代码
Containers: {
  "7ac06954919d215f3fc13bed1efdbee3895a544198f018b6fdedc338b24c50b2": {
    Name: "nestjs-adminer-1",
    EndpointID: "776561fc5ef781c3a2fa3a468c46a352bed24a000adb89c1d8ed848158a0a9c5",
    MacAddress: "02:42:ac:14:00:03",
    IPv4Address: "172.20.0.3/16", # 容器的 ip 地址
    IPv6Address: "",
  },
  "9943fd72d3d12b4883adb699408d6745ea6ecf0df14cf465e27fd7b69f27d06f": {
    Name: "nestjs-db-1",
    EndpointID: "45ccf42c7f866df1a5628d9e45b88e212a1cdc3bb44061533d0312a6068eb18e",
    MacAddress: "02:42:ac:14:00:02",
    IPv4Address: "172.20.0.2/16", # 容器的 ip 地址
    IPv6Address: "",
  },
}

nestjs/cli

安装官方脚手架工具 nestjs/cli

bash 复制代码
pnpm add -g @nestjs/cli

nestjs 创建项目

bash 复制代码
nest new <project-name>

使用 nest 创建模块可以使用 nest g <schematic> xxx,其中 schematicnest 的模块类型,xxx 为模块名称

bash 复制代码
 generate|g [options] <schematic> [name] [path]  Generate a Nest element.
    Schematics available on @nestjs/schematics collection:
      ┌───────────────┬─────────────┬──────────────────────────────────────────────┐
      │ name          │ alias       │ description                                  │
      │ application   │ application │ Generate a new application workspace         │
      │ class         │ cl          │ Generate a new class                         │
      │ configuration │ config      │ Generate a CLI configuration file            │
      │ controller    │ co          │ Generate a controller declaration            │
      │ decorator     │ d           │ Generate a custom decorator                  │
      │ filter        │ f           │ Generate a filter declaration                │
      │ gateway       │ ga          │ Generate a gateway declaration               │
      │ guard         │ gu          │ Generate a guard declaration                 │
      │ interceptor   │ itc         │ Generate an interceptor declaration          │
      │ interface     │ itf         │ Generate an interface                        │
      │ library       │ lib         │ Generate a new library within a monorepo     │
      │ middleware    │ mi          │ Generate a middleware declaration            │
      │ module        │ mo          │ Generate a module declaration                │
      │ pipe          │ pi          │ Generate a pipe declaration                  │
      │ provider      │ pr          │ Generate a provider declaration              │
      │ resolver      │ r           │ Generate a GraphQL resolver declaration      │
      │ resource      │ res         │ Generate a new CRUD resource                 │
      │ service       │ s           │ Generate a service declaration               │
      │ sub-app       │ app         │ Generate a new application within a monorepo │
      └───────────────┴─────────────┴──────────────────────────────────────────────┘

热重载

nest 配置 webpack 实现热重载,文档:recipes/hot-reload

在后端当中实现热重载的意义不是很大,了解即可

使用 vscode 调试

在项目根目录下创建 .vscode/launch.json 文件,内容如下:

json 复制代码
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch via NPM",
      "request": "launch",
      "runtimeArgs": ["run-script", "start:debug"], // 这里填写脚本名称
      "runtimeExecutable": "pnpm", // 这里填写包管理器名称
      "runtimeVersion": "20.11.0", // 这里填写 node 版本
      "internalConsoleOptions": "neverOpen", // 不用默认 debug 终端
      "console": "integratedTerminal", // 使用自己配置的终端
      "skipFiles": ["<node_internals>/**"],
      "type": "node"
    }
  ]
}

使用 chrome 调试

视频教程:使用 chrome 调试

初识 nest

nest 采用 expresshttp 服务

nestjs 世界中,所有的东西都是模块,所有的服务,路由都是和模块相关联的

项目入口是 src/main.ts,根模块是 app.module.ts

bash 复制代码
src
  - main.ts
  - app.module.ts

app.module.ts 中包含 app.service.tsapp.controller.ts

js 复制代码
@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

启动服务,访问 localhost:3000/app 就可以看到 Hello world

js 复制代码
@Controller("app")
export class AppController {
  constructor(private UserService: UserService) {}

  @Get()
  getUser(): any{
    return {
      code: 0,
      data: "Hello world",
      msg: 'ok',
    };
  }
}

添加接口前缀

添加接口前缀:app.setGlobalPrefix('api/v1')

js 复制代码
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix("api/v1");
  await app.listen(3000);
}
bootstrap();

创建各模块

创建 modulecontrollerservice 方法,使用官方的脚手架工具创建,会自动将创建的模块添加到 app.module.ts 中,和放入自身的 xxx.module.ts

比如创建 user 模块

bash 复制代码
nest g controller user --no-spec # 创建 controller, --no-spec 表示不生成测试文件
nest g service user --no-spec # 创建 service, --no-spec 表示不生成测试文件
nest g module user --no-spec # 创建 module, --no-spec 表示不生成测试文件
js 复制代码
// 使用 nest g module user 创建 user.module.ts,会自动将 userModule 添加到 AppModule 中
@Module({
  imports: [UserModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

// 使用 nest g controller user 创建的 user.controller.ts,会自动将 UserController 添加到 user.module.ts 中
// 使用 nest g service user 创建的 user.service.ts,会自动将 UserService 添加到 user.module.ts 中
@Module({
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

nest 生命周期

各个声明周期的作用如下图:

  • 使用 Module 来组织应用程序
  • @Module 装饰器来描述模块
  • 模块中有 4 大属性:
    • imports:一个模块导入其他模块
    • providers:处理 service
    • controllers:处理请求
    • exports:一个模块需要被其他模块导入

接口服务逻辑

一个接口服务包括下面五大块:

  • 请求数据校验
  • 请求认证(鉴权设计)
  • 路由
  • 功能逻辑
  • 数据库操作

如下图所示:

nestjs 中常见用装饰器

一个请求中,用到的装饰器如下图所示:

  • @Post 装饰器表示这是一个 POST 请求,如果是 GET 请求,用 @Get 装饰器,其他的请求方法同理
  • @Params 装饰器获取请求路径上的参数
  • @Query 装饰器获取请求查询参数
  • @Body 装饰器获取请求体中的参数
  • @Headers 装饰器获取请求头中的参数
  • @Req 装饰器获取请求中所有的参数

在使用请求方法的装饰器时,要注意,如果路径是 /:xxx 的形式可能会出现问题, 如下所示,请求不会进入到 getProfile 方法中

ts 复制代码
@Controller("user")
export class UserController {
  @Get("/:id")
  getUser() {
    return "hello world";
  }
  @Get("/profile")
  getProfile() {
    return "profile";
  }
}

解决方法有三种:

  1. @Get("/:xxx") 的形式放到当前 Controller 的最下面
  2. 用路径隔离:@Get("/path/:xxx")
  3. 单独写一个 Controller

配置文件

dotenv

dotenv 这个库是用来读取本地的 .env 文件

.env 方案的缺点是,无法使用嵌套的方式书写配置,当配置比较多时,不太好管理

js 复制代码
require("dotenv").config();
console.log(process.env);

config

config 这个库的配置文件是 json 的形式,默认读取当前目录下的 config/default.json 文件

js 复制代码
const config = require("config");
console.log(config.get("db")); // 读取到配置文件中的 db 属性

它可以通过 export NODE_ENV=production 方式读取 production.json,然后就会将 production.json 中的配置合并到 default.json

@nestjs/config

官方也提供了配置文件的解决方案 @nestjs/config,它是内部是基于 dotenv

app.module.ts 中引入 ConfigModule,需要设置 isGlobaltrue,这样就可以在其他模块中使用 ConfigService

ts 复制代码
import { ConfigModule } from "@nestjs/config";

@Module({
  imports: [
    ConfigModule.forRoot({
      // 需要设置为 true
      isGlobal: true,
    }),
    UserModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

使用:

ts 复制代码
import { ConfigService } from "@nestjs/config";

@Controller("user")
export class UserController {
  constructor(private UserService: UserService, private configService: ConfigService) {}

  @Get()
  getUser() {
    // 就可以获取到 .env 中的 USERNAME 属性
    console.log(this.configService.get("USERNAME"));
    return this.UserService.getUsers();
  }
}

如果不在 app.module.ts 中设置 isGlobaltrue 的话,就需要在使用的地方引入,才能在当前的 controller 中使用

合并不同的 .env 文件

当配置项需要区分不同环境时,比如 .env.development.env.production 时,有些功能的配置如果在这两个配置文件中都写一遍的话,就会造成代码冗余,维护成本也比较大

我们可以通过 load 参数实现加载公共配置文件,envFilePath 加载对应环境的配置文件

首先在 package.json 中配置执行各环境的命令,这里是借助 cross-env 这个库来实现的:

json 复制代码
{
  // dev 环境配置 NODE_ENV=development
  "start:dev": "cross-env NODE_ENV=development nest start --watch",
  // prod 环境配置 NODE_ENV=production
  "start:prod": "cross-env NODE_ENV=production node dist/main"
}

然后在 app.module.ts 中就可以通过 proces.env.NODE_ENV 来获取当前环境值,然后拼接 .env 文件名,传入给 envFilePath 参数

加载公共配置文件使用 load 参数,传入一个函数

ts 复制代码
import * as dotenv from "dotenv";

const envFilePath = `.env.${process.env.NODE_ENV || ""}`;

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      // 加载对应环境的配置参数
      envFilePath,
      // 加载公共配置参数
      load: [() => dotenv.config({ path: ".env" })],
    }),
    UserModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

加载 yaml 文件

nestjs 中加载 yaml 需要自己写读取方法

  1. 这个是多环境读取方法,使用 lodash.merge 合并不同配置文件
  2. 使用 js-yaml 解析 yaml 文件
  3. 使用内置模块 fspath 读取文件
ts 复制代码
import { readFileSync } from "fs";
import * as yaml from "js-yaml";
import { join } from "path";
import { merge } from "lodash";

const YAML_COMMON_CONFIG_FILENAME = "config.yaml";
const filePath = join(__dirname, "../config", YAML_COMMON_CONFIG_FILENAME);

const envPath = join(__dirname, "../config", `config.${process.env.NODE_ENV || ""}.yaml`);
const commonConfig = yaml.load(readFileSync(filePath, "utf8"));
const envConfig = yaml.load(readFileSync(envPath, "utf8"));

// 因为 ConfigModule 的 load 参数接收的是函数
export default () => merge(commonConfig, envConfig);

配置文件参数验证

配置文件参数验证,使用 Joi

ts 复制代码
const schema = Joi.object({
  // 校验端口是不是数字,并且是不是 3305,3306,3307,默认使用 3306
  PORT: Joi.number().valid(3305, 3306, 3307).default(3306),
  // 这个值会被动态加载
  DATABASE: Joi.string().required(),
});

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      // 加载对应环境的配置参数
      envFilePath,
      // 加载公共配置参数
      load: [
        // 动态加载的配置文件参数校验
        () => {
          const values = dotenv.config({ path: ".env" });
          const { error } = schema.validate(values?.parsed, {
            // 允许未知的环境变量
            allowUnknown: true,
            // 如果有错误,不要立即停止,而是收集所有错误
            abortEarly: false,
          });
          if (error) throw new Error(`Validation failed - Is there an environment variable missing?${error.message}`);
          return values;
        },
      ],
      validationSchema: schema,
    }),
    UserModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

TypeOrm

TypeOrmnestjs 官方推荐的数据库操作库,需要安装 @nestjs/typeormtypeormmysql2 这三个库

为了能够从配置文件中读取数据库的连接方式,需要使用 TypeOrmModule.forRootAsync 方法

ts 复制代码
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (configService: ConfigService) =>
    ({
      type: configService.get("DB_TYPE"),
      host: configService.get("DB_HOST"),
      port: configService.get("DB_PORT"),
      username: configService.get("DB_USERNAME"),
      password: configService.get("DB_PASSWORD"),
      database: configService.get("DB"),
      entities: [],
      synchronize: configService.get("DB_SYNC"),
      logging: ["error"],
    } as TypeOrmModuleOptions),
});

创建表

  1. 在类上打上 @Entity 装饰器
  2. 主键使用 @PrimaryGeneratedColumn 装饰器
  3. 其他的字段使用 @Column 装饰器
ts 复制代码
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  username: string;
  @Column()
  password: string;
}

然后在 app.module.tsTypeOrmModule.forRootAsync.entities 中引入 user.entity,重启服务就能看到数据库中创建了 user

一对一

profile.user_id 需要对应 user.id

pofile 类中,不要显示指定 user_id,而是使用 @OneToOne 装饰器和 @JoinColumn 装饰器

  1. @OneToOne 装饰器接收一个函数,这个函数返回 User
  2. @JoinColumn 装饰器接收一个对象,这个对象的 name 属性就是 profile 表中的 user_id 字段
    1. 不使用 name 属性,默认将 Profile.userUser.id 驼峰拼接
ts 复制代码
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm";
import { User } from "./user.entity";

@Entity()
export class Profile {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  gender: number;

  @Column()
  photo: string;

  @Column()
  address: string;

  @OneToOne(() => User)
  @JoinColumn({ name: "user_id" })
  user: User;
}

一对多

一对多使用 @OneToMany 装饰器,第一个参数和 @OneToOne 一样

主要是第二个参数,第二个参数的作用是告诉 TypeOrm 应该和 Logs 中哪个字段进行关联

ts 复制代码
import { Logs } from "src/logs/logs.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  username: string;
  @Column()
  password: string;

  @OneToMany(() => Logs, (logs) => logs.user)
  logs: Logs[];
}

Logs 表就是多对一的关系,使用 @ManyToOne,和 User.logs 关联

ts 复制代码
import { User } from "src/user/user.entity";
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Logs {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  path: string;

  @Column()
  method: string;

  @Column()
  data: string;

  @Column()
  result: string;

  @ManyToOne(() => User, (user) => user.logs)
  @JoinColumn()
  user: User;
}

多对多

多对多使用 @ManyToMany@JoinTable 两个装饰器

用法和 @OneToMany 一样

@JoinTable 装饰器的作用是创建一张中间表,用来记录两个表之间的关联关系

ts 复制代码
import { Logs } from "src/logs/logs.entity";
import { Roles } from "src/roles/roles.entity";
import { Column, Entity, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  username: string;
  @Column()
  password: string;

  @ManyToMany(() => Roles, (role) => role.users)
  roles: Roles[];
}
ts 复制代码
import { User } from "src/user/user.entity";
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Roles {
  @PrimaryGeneratedColumn()
  id: number;
  @Column()
  name: string;

  @ManyToMany(() => User, (user) => user.roles)
  @JoinTable({ name: "users_roles" })
  users: User[];
}

查询

TypeOrm 中查询,需要使用 @InjectRepository 装饰器,注入一个 Repository 对象

Repository 对象会包含各种对数据库的操作方法,比如 findfindOnesaveupdatedelete

ts 复制代码
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "./user.entity";
import { Repository } from "typeorm";

@Injectable()
export class UserService {
  @InjectRepository(User)
  private readonly userRepository: Repository<User>;
}
  1. 查询全部 this.userRepository.find()
  2. 查询某条数据 this.userRepository.findOne({ where: { id: 1 } })
  3. 新建数据
    1. 创建数据 const user = this.userRepository.create({ username: "xxx", password: "xxx" })
    2. 报错数据 this.userRepository.save(user)
  4. 更新数据 this.userRepository.update(id, user)
  5. 删除数据 this.userRepository.delete(id)
  6. 一对一 this.userRepository.findOne({ where: { id }, relations: ["profile"] })
  7. 一对多
    1. 先查询出 user 实体 const user = this.userRepository.findOne({ where: { id } })
    2. 在用 user 实体作为 where 条件,this.logsRepository.find({ where: { user }, relations: ["user"] })

使用 QueryBuilder 查询

ts 复制代码
this.logsRepository
  .createQueryBuilder("logs")
  .select("logs.result", "result")
  .addSelect("COUNT(logs.result)", "count")
  .leftJoinAndSelect("logs.user", "user")
  .where("user.id = :id", { id })
  .groupBy("logs.result")
  .orderBy("count", "DESC")
  .getRawMany();

这个 QueryBuilder 语句对应下的 sql 语句

sql 复制代码
select logs.result as result, COUNT(logs.result) as count from logs, user where user.id = logs.user_id and user.id = 2 group by logs.result order by count desc;

如果要使用原生 sql 语句,可以使用 this.logsRepository.query 方法

ts 复制代码
this.logsRepository.query(
  "select logs.result as result, COUNT(logs.result) as count from logs, user where user.id = logs.user_id and user.id = 2 group by logs.result order by count desc"
);

处理字段不存在

当一个参数前端没有传递过来时,那么应该怎么写这个查询条件

可以用 sqlwhere 1=1 的方式拼接 sql

ts 复制代码
const queryBuilder = this.userRepository.createQueryBuilder("user").where(username ? "user.username = :username" ? "1=1", { username })

remove 和 delete 的区别

remove 可以一次删除单个或者多个实例,并且 remove 可以触发 BeforeRemoveAfterRemove 钩子

  • await repository.remove(user);
  • await repository.remove([user1, user2, user3]);

delete 可以一次删除单个或者多个 id 实例,或者给定的条件

  • await repository.delete(user.id);
  • await repository.delete([user1.id, user2.id, user3.id]);
  • await repository.delete({ username: "xxx" });

日志

日志按照等级可分为 5 类:

  • Log:通用日志,按需进行记录(打印)
  • Warning:警告日志,比如多次进行数据库操作
  • Error:错误日志,比如数据库连接失败
  • Debug:调试日志,比如加载数据日志
  • Verbose:详细日志,所有的操作与详细信息(非必要不打印)

按照功能分类,可分为 3 类:

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

NestJS 全局日志

NestJS 默认是开启日志的,如果需要关闭的话,在 NestFactory.create 方法中,传递第二个参数 { logger: false }

ts 复制代码
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { logger: false });
  app.setGlobalPrefix("api/v1");
  console.log(123);
  await app.listen(3000);
}
bootstrap();

logger

NestJS 官方提供了一个 Logger 类,可以用来打印日志

  • logger.warn():用黄色打印出信息
  • logger.error():用红色打印出信息
  • logger.log():用绿色打印出信息
ts 复制代码
const logger = new Logger();
logger.warn();

controller 中记录日志,需要显示使用 new 创建一个 logger 实例

ts 复制代码
@Controller("user")
class UserController {
  private logger = new Logger(UserController.name);
  constructor(private UserService: UserService, private configService: ConfigService) {
    this.logger.log("UserController created");
  }

  @Get()
  getUsers() {
    this.logger.log("getUsers 请求成功");
    return this.UserService.findOne(1);
  }
}

nestjs-pino

NestJS 中使用第三方日志库 pino,需要 nestjs-pino 这个库

首先需要再用到的模块中注入,也就是在 UserModuleimports 中注入 pino 模块

ts 复制代码
@Module({
  imports: [TypeOrmModule.forFeature([User, Logs]), LoggerModule.forRoot()],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

然后在 UserController 中导入,就可以使用了,默认所有请求都会记录

ts 复制代码
@Controller("user")
class UserController {
  constructor(private UserService: UserService, private configService: ConfigService, private logger: Logger) {
    this.logger.log("UserController created");
  }
}

日志美化使用 pino-pretty,日志记录在文件中使用 pino-roll,使用方式如下:

ts 复制代码
LoggerModule.forRoot({
  pinoHttp: {
    transport: {
      targets: [
        {
          level: "info",
          target: "pino-pretty",
          options: {
            colorize: true,
          },
        },
        {
          level: "info",
          target: "pino-roll",
          options: {
            file: join("logs", "log.txt"),
            frequency: "daily",
            size: "10m",
            mkdir: true,
          },
        },
      ],
    },
  },
});

异常

当出现了异常,使用 HttpException 抛出异常

ts 复制代码
@Controller("user")
export class UserController {
  constructor(private UserService: UserService) {}

  @Get()
  getUsers() {
    const user = { isAdmin: false };
    if (!user.isAdmin) {
      throw new HttpException("Forbidden", HttpStatus.FORBIDDEN);
    }

    return this.UserService.findOne(1);
  }
}

通过 throw new HttpException() 的方式抛出异常,可以在全局的过滤器中捕获

新建 http-exception.filter.ts 文件:

  1. 新建一个 HttpException 类,实现 ExceptionFilter 接口
    1. 实现 catch 方法,这个方法会在抛出异常的时候执行
  2. 在类上使用 @Catch 装饰器,传入需要捕获的异常类
ts 复制代码
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    // 获取到上下文
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    const status = exception.getStatus();

    response.status(status).json({
      code: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message: exception.message,
    });
  }
}

JWT

JWT 全称 JSON Web Token,由三部分构成:HeaderPayloadSignature

  • Header:规定使用的加密方式和类型

    ts 复制代码
    {
      alg: "HS256", // 加密方式
      typ: "JWT",   // 类型
    };
  • Payload:包含一些用户信息

    ts 复制代码
    {
      sub: "2024-01-01",
      name: "Brian",
      admin: true,
    };
  • Signaturebase64Header + base64Payload + secret 生成的字符串

    ts 复制代码
    HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);

它的特点有三个:

  • CSRF(主要是伪造请求,有 Cookie
  • 适合移动应用
  • 无状态,编码数据

JWT 工作原理,如图所示

安装依赖

安装 @nestjs/jwtpassport-jwtpassport 这三个库

bash 复制代码
pnpm i @nestjs/jwt passport-jwt passport

auth 模块使用 userService

UserModule 中将 UserService 导出,也就是写在 exports 属性上面

ts 复制代码
@Module({
  imports: [TypeOrmModule.forFeature([User, Logs])],
  controllers: [UserController],
  providers: [UserService],
  // 导出 UserService
  exports: [UserService],
})
export class UserModule {}

AuthModule 中导入 UserModule,也就是写在 imports 属性上面

ts 复制代码
@Module({
  imports: [UserModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

AuthService 中注入,就可以使用 AuthService 中的方法了

ts 复制代码
@Injectable()
export class AuthService {
  @Inject()
  private userService: UserService;

  async signin(username: string, password: string) {
    return await this.userService.findAll({ username } as getUserDto);
  }
}

管道

nestjs 会在调用方法前插入一个管道,管道会先拦截方法的调用参数,进行转换或者是验证处理,然后用转换好或者是验证好的参数调用原方法

管道有两个应用场景:

  • 转换:管道将输入数据转换为所需的数据输出(例如,将字符串转换为整数)
  • 验证:对输入数据进行验证,如果验证成功继续传递;验证失败则抛出异常

nestjs 中的管道分为三种:

  • 控制器级别:对整个控制器生效
  • 变量:只对某个变量生效
  • 全局:对整个应用生效

使用方式如图所示:

使用

安装 class-validatorclass-transformer 两个库

ts 复制代码
pnpm i class-transformer class-validator

在全局添加管道,参数 whitelist 属性作用是过滤掉前端传过来的字段中后端在 dto 中没有定义的字段

ts 复制代码
app.useGlobalPipes(
  new ValidationPipe({
    // 过滤掉前端传过来的脏数据
    whitelist: true,
  })
);

定义一个 dto

自定义错误信息,message 中有几个变量:

  • $value:当前用户传入的值
  • $property:当前属性名
  • $target:当前类
  • $constraint1:第一个约束
ts 复制代码
import { IsNotEmpty, IsString, Length } from "class-validator";

export class SigninUserDto {
  @IsString()
  @IsNotEmpty()
  @Length(6, 20, {
    message: "用户名长度必须在 $constraint1 到 $constraint2 之间,当前传的值是 $value",
  })
  username: string;

  @IsString()
  @IsNotEmpty()
  @Length(6, 20, {
    message: "密码长度必须在 $constraint1 到 $constraint2 之间,当前传的值是 $value",
  })
  password: string;
}

过滤掉不需要的字段

@Body() 中使用管道 CreateUserPipe

ts 复制代码
@Controller("user")
@UseFilters(new TypeormFilter())
export class UserController {
  constructor(private UserService: UserService) {}

  @Post()
  // 是用管道 CreateUserPipe
  addUser(@Body(CreateUserPipe) dto: CreateUserPipe): any {
    const user = dto as User;
    return this.UserService.create(user);
  }
}

使用 nest/cli 工具创建管道

bash 复制代码
nest g pi user/pipes/create-user --no-spec

CreateUserPipe 实现 PipeTransform 接口,这个接口有一个 transform 方法,这个方法会在管道中执行

ts 复制代码
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import { CreateUserDto } from "src/user/dto/create-user.dto";

@Injectable()
export class CreateUserPipe implements PipeTransform {
  transform(value: CreateUserDto, metadata: ArgumentMetadata) {
    if (value.roles && value.roles instanceof Array && value.roles.length > 0) {
      if (value.roles[0]["id"]) {
        value.roles = value.roles.map((role) => role.id);
      }
    }
    return value;
  }
}

集成 jwt

  1. 新建文件 auth.strategy.ts,写下如下方法:

    ts 复制代码
    import { PassportStrategy } from "@nestjs/passport";
    import { Strategy, ExtractJwt } from "passport-jwt";
    import { ConfigService } from "@nestjs/config";
    import { Injectable } from "@nestjs/common";
    @Injectable()
    export class JwtStrategy extends PassportStrategy(Strategy) {
      constructor(protected configService: ConfigService) {
        super({
          jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
          ignoreExpiration: false,
          secretOrKey: configService.get<string>("SECRET"),
        });
      }
      async validate(payload: any) {
        return { userId: payload.sub, username: payload.username };
      }
    }
  2. auth.module.ts 中引入 JwtStrategy

    1. 先引入 PassportModule
    2. JwtModule.registerAsync 读到配置文件中的 SECRET
    3. providers 中引入 JwtStrategy
    ts 复制代码
    import { Module } from "@nestjs/common";
    import { AuthService } from "./auth.service";
    import { AuthController } from "./auth.controller";
    import { UserModule } from "src/user/user.module";
    import { PassportModule } from "@nestjs/passport";
    import { JwtModule } from "@nestjs/jwt";
    import { ConfigModule, ConfigService } from "@nestjs/config";
    import { JwtStrategy } from "./auth.strategy";
    @Module({
      imports: [
        UserModule,
        PassportModule,
        JwtModule.registerAsync({
          imports: [ConfigModule],
          useFactory: async (configService: ConfigService) => {
            return {
              // 密钥
              secret: configService.get<string>("SECRET"),
              // 过期时间 全局
              signOptions: {
                expiresIn: "1d",
              },
            };
          },
          inject: [ConfigService],
        }),
      ],
      providers: [AuthService, JwtStrategy],
      controllers: [AuthController],
    })
    export class AuthModule {}
  3. 登录时生成 token,将 jwtService 注入到 auth.service.ts 中,调用 this.jwtService.signAsync 方法生成 token,供 signin 接口调用

    ts 复制代码
    import { Inject, Injectable, UnauthorizedException } from "@nestjs/common";
    import { UserService } from "../user/user.service";
    import { JwtService } from "@nestjs/jwt";
    @Injectable()
    export class AuthService {
      @Inject()
      private userService: UserService;
      @Inject()
      private jwtService: JwtService;
      async signin(username: string, password: string) {
        const user = await this.userService.find(username);
        if (user && user.password === password) {
          return await this.jwtService.signAsync(
            {
              username: user.username,
              sub: user.id,
            }
            // 局部过期时间,一般用于 refresh token
            // { expiresIn: "1d" }
          );
        }
        throw new UnauthorizedException();
      }
    }
  4. 在需要鉴权的接口上使用 @UseGuards(AuthGuard("jwt"))AuthGuardpassport 提供的一个守卫,jwtJwtStrategy 的名字

    ts 复制代码
    import { Controller, Get, UseFilters, UseGuards } from "@nestjs/common";
    import { UserService } from "./user.service";
    import { TypeormFilter } from "src/filters/typeorm.filter";
    import { AuthGuard } from "@nestjs/passport";
    @Controller("user")
    @UseFilters(new TypeormFilter())
    export class UserController {
      constructor(private UserService: UserService) {}
      @Get("profile")
      @UseGuards(AuthGuard("jwt")) // 只是验证是否带有 token
      getUserProfile() {
        return this.UserService.findProfile(2);
      }
    }

验证用户是不是有权限进行后续操作

使用 nest/cli 工具创建 guard 守卫

bash 复制代码
nest g gu guards/admin --no-spec

判断当前用户是否是 角色2

ts 复制代码
import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
import { User } from "src/user/user.entity";
import { UserService } from "src/user/user.service";

@Injectable()
export class AdminGuard implements CanActivate {
  @Inject()
  private readonly userService: UserService;

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 获取请求对象
    const req = context.switchToHttp().getRequest();
    // 获取请求中的用户信息,进行逻辑上的判断 -> 角色判断
    const user = (await this.userService.find(req.user.id)) as User;
    console.log("user", user);
    // 判断用户是否是角色2
    if (user.roles.filter((role) => role.id === 2).length > 0) {
      return true;
    }
    return false;
  }
}

装饰器执行顺序:

  1. 从下往上执行
  2. UseGuards 传递了多个守卫,那么会从左往右执行
ts 复制代码
import { Controller, Get, UseFilters, UseGuards } from "@nestjs/common";
import { UserService } from "./user.service";
import { TypeormFilter } from "src/filters/typeorm.filter";
import { AuthGuard } from "@nestjs/passport";
import { AdminGuard } from "../guards/admin/admin.guard";

@Controller("user")
@UseFilters(new TypeormFilter())
export class UserController {
  constructor(private UserService: UserService) {}
  @Get("profile")
  // 第一个是验证有没有 token,第二个是验证有没有角色2的权限
  @UseGuards(AuthGuard("jwt"), AdminGuard)
  getUserProfile() {
    return this.UserService.findProfile(2);
  }
}

密码加密

密码加密使用 argon2

安装:

ts 复制代码
pnpm i argon2

加码:

ts 复制代码
userTmp.password = await argon2.hash(userTmp.password);

验证:

ts 复制代码
const isPasswordValid = await argon2.verify(user.password, password);

拦截器

使用 nest/cli 创建拦截器

bash 复制代码
nest g itc interceptors/serialize --no-spec
ts 复制代码
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, map } from "rxjs";

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 拦截器执行之前
    return next.handle().pipe(
      map((data) => {
        // 拦截器执行之后
        return data;
      })
    );
  }
}

全局使用:

ts 复制代码
app.useGlobalInterceptors(new SerializeInterceptor());

局部使用,在路由或者控制器上:

ts 复制代码
@useInterceptors(SerializeInterceptor)

序列化

在路由上使用

ts 复制代码
import { ClassSerializerInterceptor } from "@nestjs/common";
@UseInterceptors(ClassSerializerInterceptor)

不需要响应给前端的字段,在 entity 中使用 @Exclude 装饰器

ts 复制代码
import { Logs } from "src/logs/logs.entity";
import { Roles } from "src/roles/roles.entity";
import { Column, Entity, ManyToMany, OneToMany, OneToOne, PrimaryGeneratedColumn } from "typeorm";
import { Profile } from "./profile.entity";
import { Exclude } from "class-transformer";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  @Column({ unique: true })
  username: string;
  @Column()
  @Exclude() // 不需要导出的字段
  password: string;

  @OneToMany(() => Logs, (logs) => logs.user)
  logs: Logs[];

  @ManyToMany(() => Roles, (role) => role.users)
  roles: Roles[];

  @OneToOne(() => Profile, (profile) => profile.user, { cascade: true })
  profile: Profile;
}

处理输入的数据,使用 @Expose 装饰器,在路由中只有 msg 字段可以被获取到

ts 复制代码
class Test {
  @Expose()
  msg: string;
}

@UseInterceptors(new SerializeInterceptor(Test))
getUsers(@Query() query: getUserDto): any {
  console.log(query);
  return this.UserService.findAll(query);
}
ts 复制代码
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { plainToClassFromExist } from "class-transformer";
import { Observable, map } from "rxjs";

@Injectable()
export class SerializeInterceptor implements NestInterceptor {
  constructor(private dto: any) {}
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        return plainToClassFromExist(this.dto, data, {
          excludeExtraneousValues: true,
        });
      })
    );
  }
}
相关推荐
活宝小娜37 分钟前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点39 分钟前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow40 分钟前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o41 分钟前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
刚刚好ā2 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
qq_17448285752 小时前
springboot基于微信小程序的旧衣回收系统的设计与实现
spring boot·后端·微信小程序
锅包肉的九珍3 小时前
Scala的Array数组
开发语言·后端·scala
心仪悦悦3 小时前
Scala的Array(2)
开发语言·后端·scala
yqcoder3 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
2401_882727573 小时前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架