工具安装
node 版本管理工具
nvm
是 node
版本管理工具
安装 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 源管理工具
nrm
是 npm
源管理工具
安装 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
,其中 schematic
为 nest
的模块类型,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
采用 express
的 http
服务
在 nestjs
世界中,所有的东西都是模块,所有的服务,路由都是和模块相关联的
项目入口是 src/main.ts
,根模块是 app.module.ts
bash
src
- main.ts
- app.module.ts
app.module.ts
中包含 app.service.ts
和 app.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();
创建各模块
创建 module
、controller
、service
方法,使用官方的脚手架工具创建,会自动将创建的模块添加到 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";
}
}
解决方法有三种:
- 将
@Get("/:xxx")
的形式放到当前Controller
的最下面 - 用路径隔离:
@Get("/path/:xxx")
- 单独写一个
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
,需要设置 isGlobal
为 true
,这样就可以在其他模块中使用 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
中设置 isGlobal
为 true
的话,就需要在使用的地方引入,才能在当前的 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
需要自己写读取方法
- 这个是多环境读取方法,使用
lodash.merge
合并不同配置文件 - 使用
js-yaml
解析yaml
文件 - 使用内置模块
fs
和path
读取文件
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
TypeOrm
是 nestjs
官方推荐的数据库操作库,需要安装 @nestjs/typeorm
、typeorm
、mysql2
这三个库
为了能够从配置文件中读取数据库的连接方式,需要使用 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),
});
创建表
- 在类上打上
@Entity
装饰器 - 主键使用
@PrimaryGeneratedColumn
装饰器 - 其他的字段使用
@Column
装饰器
ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
}
然后在 app.module.ts
中 TypeOrmModule.forRootAsync.entities
中引入 user.entity
,重启服务就能看到数据库中创建了 user
一对一
profile.user_id
需要对应 user.id
在 pofile
类中,不要显示指定 user_id
,而是使用 @OneToOne
装饰器和 @JoinColumn
装饰器
@OneToOne
装饰器接收一个函数,这个函数返回User
类@JoinColumn
装饰器接收一个对象,这个对象的name
属性就是profile
表中的user_id
字段- 不使用
name
属性,默认将Profile.user
和User.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
对象会包含各种对数据库的操作方法,比如 find
、findOne
、save
、update
、delete
等
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>;
}
- 查询全部
this.userRepository.find()
- 查询某条数据
this.userRepository.findOne({ where: { id: 1 } })
- 新建数据
- 创建数据
const user = this.userRepository.create({ username: "xxx", password: "xxx" })
- 报错数据
this.userRepository.save(user)
- 创建数据
- 更新数据
this.userRepository.update(id, user)
- 删除数据
this.userRepository.delete(id)
- 一对一
this.userRepository.findOne({ where: { id }, relations: ["profile"] })
- 一对多
- 先查询出
user
实体const user = this.userRepository.findOne({ where: { id } })
- 在用
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"
);
处理字段不存在
当一个参数前端没有传递过来时,那么应该怎么写这个查询条件
可以用 sql
中 where 1=1
的方式拼接 sql
ts
const queryBuilder = this.userRepository.createQueryBuilder("user").where(username ? "user.username = :username" ? "1=1", { username })
remove 和 delete 的区别
remove
可以一次删除单个或者多个实例,并且 remove
可以触发 BeforeRemove
和 AfterRemove
钩子
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
这个库
首先需要再用到的模块中注入,也就是在 UserModule
的 imports
中注入 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
文件:
- 新建一个
HttpException
类,实现ExceptionFilter
接口- 实现
catch
方法,这个方法会在抛出异常的时候执行
- 实现
- 在类上使用
@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
,由三部分构成:Header
,Payload
,Signature
-
Header
:规定使用的加密方式和类型ts{ alg: "HS256", // 加密方式 typ: "JWT", // 类型 };
-
Payload
:包含一些用户信息ts{ sub: "2024-01-01", name: "Brian", admin: true, };
-
Signature
:base64
的Header
+base64
的Payload
+secret
生成的字符串tsHMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);
它的特点有三个:
- 防
CSRF
(主要是伪造请求,有Cookie
) - 适合移动应用
- 无状态,编码数据
JWT
工作原理,如图所示
安装依赖
安装 @nestjs/jwt
、passport-jwt
、passport
这三个库
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-validator
、class-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
-
新建文件
auth.strategy.ts
,写下如下方法:tsimport { 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 }; } }
-
在
auth.module.ts
中引入JwtStrategy
- 先引入
PassportModule
- 在
JwtModule.registerAsync
读到配置文件中的SECRET
- 在
providers
中引入JwtStrategy
tsimport { 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 {}
- 先引入
-
登录时生成
token
,将jwtService
注入到auth.service.ts
中,调用this.jwtService.signAsync
方法生成token
,供signin
接口调用tsimport { 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(); } }
-
在需要鉴权的接口上使用
@UseGuards(AuthGuard("jwt"))
,AuthGuard
是passport
提供的一个守卫,jwt
是JwtStrategy
的名字tsimport { 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;
}
}
装饰器执行顺序:
- 从下往上执行
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,
});
})
);
}
}