该文章是【基于Nest.js+React的全栈项目】系列文章之一,整体信息详见:
开篇
本文是系列文章第一篇,不想再系统的讲一遍nestjs如何入门,但如果没使用过nestjs的话,确实需要一个过程,自己之前也跟着别人的教程撸过一个demo项目,正好把文章和我自己的demo都贴出来,方便大家快速跨过新手村:
掘友文章:
我的demo项目: nestjs-starter
- 上面的四篇文章从nestjs的基础使用到jwt认证、typeorm、mysql、redis等都串了一遍。写的比较早,依赖库版本可能也比较老,所以可以与我写的demo一起使用。
- 上面文章有人反馈坑多,我在coding的时候也发现一些问题,所以文章其实只是思路和大纲,具体代码肯定是不能照搬的,结合ai编程,其实一些小细节也无伤大雅。
- 接下来我会结合上面的文章和自己的demo项目做一些必要的讲解,不过还是希望还是先把上面的四篇文章过一遍。
推荐两种阅读顺序:
- 高效版:从我这篇文章开始读,读完 clone 项目先跑起来,再把代码捋一遍,如果不理解且没解释到位,再去翻下上面掘友的文章。
- 渐进版:一点点把上面掘优的文章读完,想动手操练的时候再直接clone本文章demo。
nestjs-starter 项目使用
- 该项目是基于 nest 脚手架一步步coding的,如果不想直接clone,也可以先
nest new nest-starter
看下基础的模板代码,这样能更清晰一些。 git clone git@github.com:hellozhangran/nestjs-starter.git
拉取项目cd nestjs-starter && pnpm install
安装依赖- 启动 本地 mysql 和 redis 服务。
- 如何使用 mysql 可自行查阅,我这里用的 8.4.0 的macOS 版本
- 如何使用 redis 可参考我之前的记录:工具篇-Redis
- 可视化工具可以使用:Navicat Premium Lite 一个软件可同时管理 mysql 和 redis
- 在 mysql 中创建一个名为
typeorm-test
的数据库备用
- 启动完数据库服务后,需要修改项目根目录的 .env 文件,改成自己的配置,参考如下:
// 数据库相关配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PWD=12345678
DB_DATABASE=typeorm-test
//Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
// 故意空着,一般redis不需要设置密码
REDIS_PWD=
// 这个secret可以随便写,一般会使用crypto生成一个
JWT_SECRET=33b28fb03f649f0b053b1f15f86017cf56ff1ecfacf3fe772d7fe05c3e2963ea
pnpm start
启动服务- 服务启动后浏览器直接访问:http://localhost:3000/api/hello 就可以调到一个最基础的get请求里,返回 “Hello World!”
- 推荐使用 Apifox 这个工具来访问自己的本地请求,它有个快捷请求功能,感觉很好用。还可以统一设置 Header Authorization 方便模拟登陆后的 jwt 权限认证。
Tips:该项目没有加 Swagger Api 功能,防止增加太多代码增加理解成本,后续可按需自己加。
核心逻辑梳理
登陆与鉴权逻辑
该 demo 项目最复杂的逻辑就是登陆与鉴权逻辑了,现在把这块单独拿出来串一下:
/api/user/register
第一步用户注册,把用户名/密码传给接口- 简单的非空校验,进入信息存储到数据库的环节。
- user.entity.ts 中定义了预操作,会把passport自动加密后存入数据库
@BeforeInsert()
async encryptPassword() {
this.password = await bcrypt.hash(this.password, 10);
}
/api/auth/login
登陆逻辑- 登陆时通过通过前置守卫
@UseGuards(AuthGuard('local'))
先检查用户名密码是否合法 - 检查合法后,会创建使用
jwtService
创建 token ,并返回给前端 - 前端接收到 token 后会存放到 localStorage 中,后续所有操作都会默认通过 Header Authoration 字段带上该 token 。
- 登陆时通过通过前置守卫
- 请求其他常规接口。通过
@UseGuards(AuthGuard('jwt'))
守卫设置当前接口需要jwt校验。- 后续可以通过全局Guard把所有接口都加上 jwt 守卫
- 访问到该接口时先校验jwt token是否合法,如果合法则直接把user信息注入到 request.user对象上,后续操作如何需要用户信息就不用单独取了。
api/post/create
该接口通过@UseGuards(AuthGuard('jwt'), RoleGuard)
加了jwt 校验和角色权限校验
@Roles('root', 'author')
@UseGuards(AuthGuard('jwt'), RoleGuard)
create(@Body() createPostDto: CreatePostDto, @Req() req: { user: UserEntity }) {
return this.postService.create(req.user, createPostDto);
}
- 除了之前说的jwt校验,会从user信息中拿到role信息,比对下是否有 root 或 author 权限。
上面就是目前项目中的鉴权相关逻辑,很简单直接。当然还不完善,会把角色对应的接口权限给抽出来,实现可配置,而不是像现在这样写死。
Redis 使用场景
当使用了 mysql 存储数据的时候,再看到 redis 肯定会有一个疑问为啥用了 mysql 还要用 redis 呢,主要因为它是内存数据库读写延迟低至微秒级,有很多原子性操作适合实现分布式锁等;这里不长篇大论只说几个我们项目可能用到的场景:
- 缓存常用且更新率低的数据,如用户用户信息数据。
- 利用其过期属性,设置 token 并可管理 token 生命周期。
- 分布式锁用以处理资源竞争问题。(未来会用上,如多实例下的定时任务等)
还有比如秒杀、排行、消息队列等作用,但目前在该项目尚无清晰的使用场景。
Nest 几个疑问
nest 是如何注册和使用环境变量的?
- 可以看到代码里有使用
process.env.DB_USER
获取 .env 文件里的变量的情况,也有通过configService.get('JWT_SECRET')
获取 .env 文件里的变量的情况。- 首先在 app.module.ts 文件中使用了 ConfigModule.forRoot 方法,该方法的 envFilePath 可以指定一个环境变量配置文件,如果不指定就是默认根目录下的 .env 文件。
- 该 .env 文件就会被nest首先注册到 process.env对象里,然后又组册到自己的ConfigService 模块里,这样上面的两种获取方式才得以成功。
- 另外,项目里有通过
configService.get('mysql')
的情况,这个mysql 变量不存在.env里。- 首先可以看 src/config/mysql.config.ts 文件,里面通过 registerAs 注册了 mysql 变量
- 然后 ConfigModule.forRoot 方法里通过 load 属性指定了要加载的变量。这样便可以访问
configService.get('mysql')
了。
nest中请求的生命周期是怎样的?
这里其实可以分为正常的生命周期 和 出现异常报错时的生命周期,尤其是第二种官网也没说的特别清晰。
- 情况一:正常时的生命周期
- 收到请求
- 全局绑定的中间件
- 模块绑定的中间件
- 全局守卫
- 控制层守卫
- 路由守卫
- 全局拦截器(控制器之前)
- 控制器层拦截器 (控制器之前)
- 路由拦截器 (控制器之前)
- 全局管道
- 控制器管道
- 路由管道
- 路由参数管道
- 控制器(方法处理器) 15。服务(如果有)
- 路由拦截器(请求之后)
- 控制器拦截器 (请求之后)
- 全局拦截器 (请求之后)
- 服务器响应
- 情况二:异常时候的生命周期
- 首先请求依旧按照 请求一 中的流程走,但假如某个环节报错了,则会立马跳出上面的流程。那具体跳到后面的哪个环节呢?或者说被哪个环节捕获呢?
- 有两个地方可以捕获到异常,一个是 after拦截器 里的 catchError,一个是Filter。下面重点看这两者的差异:
- Filter 会拦截来自各个阶段的异常,比如中间件、守卫、管道、拦截器等,如果"任意"阶段异常发生则请求不会继续执行后面的环节,而是直接跳到 Filter 中,但有意外情况。
- 当 Pipe、Controller、Service 中抛出异常后,请求会先流转到 after Interceptor中的catchError中,如果在after Interceptor中处理了这个异常,则会直接返回 response,如果没处理则会走到 Filter。
- Filter 只拦截没有被捕获的异常,如果用try catch 捕获了,也不会流转到Filter 中,当然也不会进入到catchError中。所以,可以直接理解成手动 try catch 的异常就不再是异常了,会直接按照你catch中的逻辑走,除非catch中又手动抛出一个异常。
Module 配置中的各项时什么作用?
以app.module.ts文件中的Module配置为例说下作用,查了很多文章都说的不够清楚或全面。
@Module({
providers: [AppService],
controllers: [AppController],
exports: [AppService],
imports: [UserModule],
})
- providers: []
- 首先写在这里服务要被@Injection修饰
- 这里面放的服务有俩作用,一让这个服务有能力使用其他服务,二是让这个服务有能力被其他服务使用
- 比如,如果AppService不写在providers里,那controller里就没法使用
- 如果AppService不写在providers里,那exports里也没法把这个服务导出去,让其他模块里的服务或controller使用。
- controllers: []
- @Controller修饰的类才能放到这里
- 放到这里,或放到providers里后,类才能调用其他服务
- exports: []
- 为了让其他模块也使用当前模块下的Service,需要把你想共享的Service放到这里。如果某服务只是想让自己模块使用,那只放到 providers 中即可,无需 exports。
- imports: []
- 导入其他模块,如UserModule,导入后,自己的controller 和 providers里的类就可以使用UserModule中配置在exports: [] 中的所有服务类。
@Gloabl
有的模块配置文件里有这个标识,说明这个模块是全局的,意味着别的模块想使用该模块的服务时就不用再手动配置 imports 了。