Nest实战 - 认证登陆
前言
本系列主要通过实现一个后台管理系统作为切入点,帮助掘友熟悉nest
的开发套路
本系列作为前后端分离项目,主要讲解nest
相关知识,前端项目知识一笔带过
由于上一章的通用模板中有测试代码,我重新上传了一份干净的代码demo/v2,如下
完整的示例代码在最后
本章属于实战系列第一章,主要讲解登陆模块相关内容,包括
- MD5加密
- 数据库设计
- JWT认证
- Nest知识点(模块循环引用、自定义装饰器)
- ts 赋值合并、交叉类型 联合类型
- 等等
技术栈
- node:19.x
- 后端:nest + mysql
- 前端:react + redux + antd
数据库设计
employee
表设计如下
表介绍
- id 为主键自增
- 用户名和身份证号做了唯一约束,防止重名
- 本系列每张表都会有创建时间、更新时间、创建人和更新人,主要为了留痕
- 其他的字段都比较中规中矩
导入数据
- 打开
Navicat
- 选择
sql
文件,在项目根目录/doc/nest-study.sql
,点击开始 - 出现这样的界面就可以了
- 然后刷新表就可以看到数据了
- 这样数据库就设计好了,接下来就可以开始开发了
登陆模块开发
新建模块
- 命令行执行
nest g res employee
- 第一步选择
REST API
- 第二步选择
y
- 第一次创建模块有点慢,耐心等待即可
- 出现下面的内容,模块就创建成功了
优化模块内容
- 删除
dto
下的文件夹,如无特殊情况直接使用实体类即可 - 删除
service
和controller
中生成的方法,保留干爽内容
更新实体类
- 打开
src/employee/entities/employee.entity.ts
,写入import { ApiProperty } from '@nestjs/swagger'; import { BaseEntity } from 'src/common/database/baseEntity'; import { Column, Entity } from 'typeorm'; @Entity() export class Employee extends BaseEntity { @ApiProperty({ description: '用户姓名', }) @Column({ comment: '用户姓名', unique: true, }) name: string; @ApiProperty({ description: '用户生日', }) @Column({ comment: '用户生日', }) birthday: Date; @ApiProperty({ description: '用户性别 0 男 1 女', }) @Column({ comment: '用户性别 0 男 1 女', }) gender: number; @ApiProperty({ description: '身份证号码', }) @Column({ comment: '身份证号码', unique: true, }) idNumber: string; @ApiProperty({ description: '手机号', }) @Column({ comment: '手机号', }) phone: string; @ApiProperty({ description: '账户名称-登陆时的账号', }) @Column({ comment: '账户名称-登陆时的账号', }) username: string; @ApiProperty({ description: '账户密码', }) @Column({ comment: '账户密码', }) password: string; @ApiProperty({ description: '状态 0:禁用,1:正常', }) @Column({ comment: '状态 0:禁用,1:正常', }) status: number; @ApiProperty({ description: '头像', }) @Column({ comment: '头像', }) avatar: string; }
- 由于
id
createTime
updateTime
createUser
updateUser
属于公共字段,抽离成独立的文件会更好 - 新建
src/common/database/baseEntity.ts
,写入import { ApiProperty } from '@nestjs/swagger'; import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; /** * 基础实体 */ @Entity() export class BaseEntity { @ApiProperty({ description: 'id', }) @PrimaryGeneratedColumn({ type: 'bigint', comment: '主键ID', }) id: string; @ApiProperty({ description: '创建时间', }) @CreateDateColumn({ comment: '创建时间', }) createTime: Date; @ApiProperty({ description: '更新时间', }) @UpdateDateColumn({ comment: '更新时间', }) updateTime: Date; @ApiProperty({ description: '创建人', }) @Column({ comment: '创建人', }) createUser: Date; @ApiProperty({ description: '更新人', }) @Column({ comment: '更新人', }) updateUser: Date; }
关闭数据同步功能
- 由于我们导入了表数据,需要
typeORM
提供的数据同步功能,不然会清空之前的数据 - 记得把数据库密码改成自己的
- 修改
根目录/.config/.dev.yml
MYSQL_CONFIG 下的synchronize
:false
即可
登陆开发
集成数据库到employee
模块中
- 修改
employee.module.ts
, 注入实体Employee
到employee
模块中import { Module } from '@nestjs/common'; import { EmployeeService } from './employee.service'; import { EmployeeController } from './employee.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Employee } from './entities/employee.entity'; @Module({ imports: [TypeOrmModule.forFeature([Employee])], controllers: [EmployeeController], providers: [EmployeeService], }) export class EmployeeModule {}
- 修改
employee.service.ts
,消费TypeOrmModule.forFeature
注入的数据模型import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Employee } from './entities/employee.entity'; @Injectable() export class EmployeeService { @InjectRepository(Employee) private readonly employeeRepository: Repository<Employee>; }
- 这样就可以在
service
中,使用当前实体了
开发前置-验证和序列化器
- 介绍
- 添加
验证
和序列化器
主要是为了对入参和返回值做校验、净化和数据转换
- 添加
验证器
- 介绍
- 安装
yarn add class-validator class-transformer
- 修改
app.module.ts
添加providers
即可{ // 管道 - 验证 provide: APP_PIPE, useFactory: () => { return new ValidationPipe({ transform: true, // 属性转换 }); }, }
序列化
- 介绍
- 序列化(
Serialization
)是一个在网络响应中返回对象前的过程。 这是一个适合转换和净化要返回给客户的数据的地方。例如,应始终从最终响应中排除敏感数据(如用户密码)。此外,某些属性可能需要额外的转换,比方说,我们只想发送一个实体的子集。手动完成这些转换既枯燥又容易出错,并且不能确定是否覆盖了所有的情况。 - 序列化器不用额外安装
npm包
,nest
内置了拦截器 提供支持
- 序列化(
- 修改
app.module.ts
添加providers
即可{ // 序列化器 - 转换和净化数据 provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor, },
- 如下所示
接口开发
代码开发
- 安装
md5
, 登陆时要用yarn add md5
- 修改
employee.controller.ts
import { Body, Controller, Post } from '@nestjs/common'; import { EmployeeService } from './employee.service'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Employee } from './entities/employee.entity'; import { CustomException } from 'src/common/exceptions/custom.exception'; import * as md5 from 'md5'; @ApiTags('员工模块') @Controller('employee') export class EmployeeController { constructor(private readonly employeeService: EmployeeService) {} @ApiOperation({ summary: '员工登陆', }) @Post('login') async login(@Body() employee: Employee) { const { username, password } = employee; const _employee = await this.employeeService.findByUsername(username); // 判断能否通过账号查询出用户信息 if (!_employee) { // 查不到,返回用户名错误信息 throw new CustomException('账号不存在,请重新输入'); } // 判断员工是否被禁用 if (_employee.status === 0) { throw new CustomException('当前员工已禁用'); } // 能查到,对输入的密码进行 md5加密,对比密码, if (md5(password) !== _employee.password) { // 不一致,返回密码错误信息 throw new CustomException('密码不对,请重新输入'); } // 密码一致,返回用户信息-需要剔除密码 // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _password, ...rest } = _employee; return rest; } }
- 修改
employee.service.ts
import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Employee } from './entities/employee.entity'; @Injectable() export class EmployeeService { @InjectRepository(Employee) private readonly employeeRepository: Repository<Employee>; /** * * @param username 用户名 * @returns 根据账户名查找用户信息 */ findByUsername(username: Employee['username']) { return this.employeeRepository.findOneBy({ username }); } }
解释
- 密码采用
md5
加密对比,是因为存储的密码一般是以秘文
的形式,防止密码泄漏后不法分子登陆账号 - 返回的时候需要
剔除
密码字段,把密码返回给前端会给不法分子可乘之机 employeeService
中的方法并不是login
,而是findByUsername
,是为了防止当前employeeService
中的方法用在其他模块中,调用此方法时会引起歧义,比如,认证模块也需要通过账户名查询用户信息,调用employeeService
下的login
方法,有很大的心智负担
测试
-
启动项目,打开swagger进行测试
http://localhost:3000/api
-
-
-
测试结果
- 我们通过输入错误的账户名、错误的密码以及正确的账户名和密码,发现和我们代码中的逻辑是一样的
认证(Authentication)
介绍
- 开发项目的过程中,尤其是
B
端,绝大部分接口是需要登陆后才能访问,为了防止不登陆,裸访问需要登陆的接口,故此需要对接口进行前置认证
技术 - Passport
nest
集成了社区优秀的认证技术Passport
- 安装
yarn add @nestjs/passport passport passport-local @nestjs/jwt passport-jwt // @types/下的包主要提供类型提示 yarn add @types/passport-local @types/passport-jwt -D
代码开发
- 打开终端,利用
nest
提供的命令安装auth
模块,当然,手动创建对应的文件夹也可以,手动创建模块之后,不要忘记在app.module.ts
中引入auth
模块nest g module auth nest g service auth
- 修改
employee.module.ts
,添加exports
,导出EmployeeService
供Auth
模块使用import { Module } from '@nestjs/common'; import { EmployeeService } from './employee.service'; import { EmployeeController } from './employee.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Employee } from './entities/employee.entity'; @Module({ imports: [TypeOrmModule.forFeature([Employee])], controllers: [EmployeeController], providers: [EmployeeService], exports: [EmployeeService], }) export class EmployeeModule {}
- 打开
auth.module.ts
导入EmployeeModule
模块,在AuthService
中会使用它import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { EmployeeModule } from '../employee/employee.module'; @Module({ imports: [EmployeeModule], providers: [AuthService], }) export class AuthModule {}
- 打开
auth.service.ts
添加validateEmployee
方法,并调用employeeService.findByUsername
,验证用户import { Injectable } from '@nestjs/common'; import { EmployeeService } from '../employee/employee.service'; import { Employee } from '../employee/entities/employee.entity'; @Injectable() export class AuthService { constructor(private readonly employeeService: EmployeeService) {} /** * * @param username 用户名 * @param pass 密码 * @returns 验证用户 */ async validateEmployee( username: Employee['username'], pass: Employee['password'], ) { const employee = await this.employeeService.findByUsername(username); if (employee?.password === pass) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password, ...rest } = employee; return rest; } return null; } }
- 新建
src/auth/strategy/local.strategy.ts
添加Passport
本地策略,进行本地身份验证import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-local'; import { AuthService } from '../auth.service'; import { Employee } from '../../employee/entities/employee.entity'; import { CustomException } from 'src/common/exceptions/custom.exception'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private readonly authService: AuthService) { super(); } /** * 本地身份验证 * @param username 用户名 * @param password 密码 */ async validate( username: Employee['username'], password: Employee['password'], ) { const employee = await this.authService.validateEmployee( username, password, ); // 验证不通过,通过自定义异常类返回权限异常信息 if (!employee) { throw CustomException.throwForbidden(); } return employee; } }
- 由于继承了
PassportStrategy
,需要把模块PassportModule
加到nest
的IOC
中 - 修改
auth.module.ts
import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { EmployeeModule } from '../employee/employee.module'; import { PassportModule } from '@nestjs/passport'; @Module({ imports: [EmployeeModule, PassportModule], providers: [AuthService], }) export class AuthModule {}
- 新建
src/auth/guard/local-auth.guard.ts
,进行守卫拦截import { Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; @Injectable() export class LocalAuthGuard extends AuthGuard('local') {}
- 本地认证这样就可以了,接下来写入
JWT认证
,最后做🙆♂统一测试 - 修改
auth.service.ts
,生成JWT
,并添加login
方法import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { EmployeeService } from '../employee/employee.service'; import { Employee } from '../employee/entities/employee.entity'; @Injectable() export class AuthService { constructor( private readonly employeeService: EmployeeService, private readonly jwtService: JwtService, ) {} /** * * @param username 用户名 * @param pass 密码 * @returns 验证用户 */ async validateEmployee( username: Employee['username'], pass: Employee['password'], ) { const employee = await this.employeeService.findByUsername(username); if (employee?.password === pass) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password, ...rest } = employee; return rest; } return null; } async login(employee: Employee) { const payload = { username: employee.username, id: employee.id }; // 使用JWT生成token return { token: this.jwtService.sign(payload), }; } }
- 修改配置文件,加入
JWT
配置,打开根目录/.config/.dev.yml
,写入HTTP: host: "localhost" port: 3000 # jwt JWT: secret: secretKey signOptions: expiresIn: 60s # mysql MYSQL_CONFIG: type: mysql # 数据库链接类型 host: localhost port: 3306 username: "root" # 数据库链接用户名 password: "Tyf12345" # 数据库链接密码 database: "nest-study" # 数据库名 logging: true # 数据库打印日志 synchronize: false # 是否开启同步数据表功能 autoLoadEntities: true # 是否自动加载实体
- 刚刚在
AuthService
中加入了jwt
生成token
的功能,故需要在auth.module.ts
中加入JwtModule
import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { EmployeeModule } from '../employee/employee.module'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { getConfig } from 'src/common/utils/ymlConfig'; import { LocalStrategy } from './strategy/local.strategy'; @Module({ imports: [ EmployeeModule, PassportModule, JwtModule.register({ ...getConfig('JWT') }), ], providers: [AuthService, LocalStrategy], }) export class AuthModule {}
- 新建
src/auth/strategy/jwt.strategy.ts
,写入import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { getConfig } from '../../common/utils/ymlConfig'; import { Employee } from '../../employee/entities/employee.entity'; import { TIdAndUsername } from '../../types/index'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ // 提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供token的标准方法 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // false 将JWT没有过期的责任委托给Passport模块 ignoreExpiration: false, // 密钥 secretOrKey: getConfig('JWT')['secret'], }); } // jwt验证 async validate( payload: Pick<Employee, TIdAndUsername> & { iat: number; exp: number }, ) { return { id: payload.id, username: payload.username, }; } }
- 新建
src/auth/guard/jwt-auth.guard.ts
, 写入import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { Observable } from 'rxjs'; import { IS_PUBLIC_KEY } from '../constants'; import { CustomException } from 'src/common/exceptions/custom.exception'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { constructor(private readonly reflector: Reflector) { super(); } override canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { // 自定义认证逻辑 const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) { return true; } return super.canActivate(context); } override handleRequest<TUser = any>( err: any, user: any, info: any, context: ExecutionContext, status?: any, ): TUser { if (err || !user) { // 可以报出一个自定义异常 throw CustomException.throwForbidden(); } return user; } }
- 新建
src/auth/constants.ts
,写入import { SetMetadata } from '@nestjs/common'; export const IS_PUBLIC_KEY = 'isPublic'; export const isPublic = () => SetMetadata(IS_PUBLIC_KEY, true);
- 修改
src/types/index.d.ts
,合并类型到exporess
的request
上import { Request } from 'express'; import { Employee } from '../employee/entities/employee.entity'; declare namespace NodeJS { interface ProcessEnv { RUNNING: string; } } export type TIdAndUsername = 'id' | 'username'; declare module 'express' { interface Request { user: Pick<Employee, TIdAndUsername>; } }
- 接下来添加
JWT守卫
到app.module.ts
中,使其全局生效import { ClassSerializerInterceptor, Module, ValidationPipe, } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { BaseExceptionFilter } from './common/exceptions/base.exception.filter'; import { HttpExceptionFilter } from './common/exceptions/http.exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { getConfig } from './common/utils/ymlConfig'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; import { addTransactionalDataSource } from 'typeorm-transactional'; import { EmployeeModule } from './employee/employee.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/guard/jwt-auth.guard'; @Module({ imports: [ // 配置 ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, load: [getConfig], }), // 数据库 TypeOrmModule.forRootAsync({ useFactory() { return { ...getConfig('MYSQL_CONFIG'), namingStrategy: new SnakeNamingStrategy(), }; }, async dataSourceFactory(options) { if (!options) { throw new Error('Invalid options passed'); } return addTransactionalDataSource(new DataSource(options)); }, }), EmployeeModule, AuthModule, ], controllers: [], providers: [ { // 管道 - 验证 provide: APP_PIPE, useFactory: () => { return new ValidationPipe({ transform: true, // 属性转换 }); }, }, { // 守卫 jwt认证 provide: APP_GUARD, useClass: JwtAuthGuard, }, { // 序列化器 - 转换和净化数据 provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor, }, { // 全局拦截器 provide: APP_INTERCEPTOR, useClass: TransformInterceptor, }, { // 全局异常 provide: APP_FILTER, useClass: BaseExceptionFilter, }, { // Http异常 provide: APP_FILTER, useClass: HttpExceptionFilter, }, ], }) export class AppModule {}
- 接下来就该修改
employee.controller.ts
中的login
方法,将注解@UseGuards(LocalAuthGuard)
和@isPublic()
挂在到login
上即可,同时为了验证认证功能
,我们再创建一个test
方法,通过自定义装饰器@User
拿到经过验证的JWT数据
,使其返回 - 新建
src/common/decorators/user.decorator.ts
,添加自定义装饰器// 自定义装饰器 import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { Request } from 'express'; import { TIdAndUsername } from 'src/types'; import { Employee } from '../../employee/entities/employee.entity'; export const User = createParamDecorator< TIdAndUsername, ExecutionContext, | Pick<Employee, TIdAndUsername> | Pick<Employee, TIdAndUsername>[TIdAndUsername] >((data, ctx) => { const user = ctx.switchToHttp().getRequest<Request>().user; if (data && user) { return user[data]; } return user; });
- 最后再回来修改
employee.controller.ts
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { EmployeeService } from './employee.service'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Employee } from './entities/employee.entity'; import { CustomException } from 'src/common/exceptions/custom.exception'; import * as md5 from 'md5'; import { AuthService } from '../auth/auth.service'; import { LocalAuthGuard } from 'src/auth/guard/local-auth.guard'; import { isPublic } from 'src/auth/constants'; import { TIdAndUsername } from '../types/index'; import { User } from 'src/common/decorators/user.decorator'; @ApiTags('员工模块') @Controller('employee') export class EmployeeController { constructor( private readonly employeeService: EmployeeService, private readonly authService: AuthService, ) {} @ApiOperation({ summary: '员工登陆', }) @isPublic() @UseGuards(LocalAuthGuard) @Post('login') async login(@Body() employee: Employee) { const { username, password } = employee; const _employee = await this.employeeService.findByUsername(username); // 判断能否通过账号查询出用户信息 if (!_employee) { // 查不到,返回用户名错误信息 throw new CustomException('账号不存在,请重新输入'); } // 判断员工是否被禁用 if (_employee.status === 0) { throw new CustomException('当前员工已禁用'); } // 能查到,对输入的密码进行 md5加密,对比密码, if (md5(password) !== _employee.password) { // 不一致,返回密码错误信息 throw new CustomException('密码不对,请重新输入'); } // 密码一致,返回用户信息-需要剔除密码 // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _password, ...rest } = _employee; const tokenObj = await this.authService.login(_employee); return { ...rest, ...tokenObj }; } @ApiOperation({ summary: '测试接口认证', }) @Get('/test') test(@User() user: Pick<Employee, TIdAndUsername>) { return user; } }
- 由于在
login
方法中调用了AuthService
中的login
方法,需要导出AuthService
- 但是直接导出,在
EmployeeModule
中引入,会存在循环引用
的问题,因为EmployeeModule
已经被AuthModule
引用了 - 所以我们可以把
AuthModule
通过装饰器@Global()
,设置为全局模块 - 更改
auth.module.ts
,添加@Global()
import { Global, Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { EmployeeModule } from '../employee/employee.module'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { getConfig } from 'src/common/utils/ymlConfig'; import { LocalStrategy } from './strategy/local.strategy'; import { JwtStrategy } from './strategy/jwt.strategy'; @Global() @Module({ imports: [ EmployeeModule, PassportModule, JwtModule.register({ ...getConfig('JWT') }), ], providers: [AuthService, LocalStrategy, JwtStrategy], exports: [AuthService], }) export class AuthModule {}
- 在
employee.controller.ts
中直接使用即可
测试
- 启动项目,打开swagger进行测试
http://localhost:3000/api
- username输入
admin
,密码输入123456
- 发现
token
被正确的返回 - 接下来把返回的token,放到
test
接口的请求头中进行测试 - 发现没有问题
- 接着我们对
token
进行更改,再进行测试,发现已经被拦截
免拦截
- 认证技术固然好使,但有些接口是不需要被认证的
- 对于不需要认证的接口,只需要在方法上加入注解
@isPublic
即可
总结
- 到现在
nest部分
的认证和登陆功能都开发完毕,对于前端来说,认证相关部分,比较难以理解,需要一定的时间去熟悉整个调用链
- 数据库设计部分,不是一下就能设计的很棒,需要多理解业务需求,根据业务场景进行不断的调整
- 登陆功能
login
方法中的代码其实比较简单,就是做各种判断,然后抛出异常,注意的点
就是,部分敏感数据
是不需要返回的 - 总结下来,就是需要多理解业务需求,逻辑要求比较严谨
写在最后
- 本章主要讲解认证登陆,如有问题欢迎在评论区留言
- 本系列不仅仅在熟悉
nest
套路,还有对ts
的学习,比如联合类型
交叉类型
类型合并
泛型
等等的使用,希望有帮助 - 前端仓库nest-study-bacnend
nest
代码已经放在 gitee demo/v3分支- 对
mysql
不熟悉的可以看下 前端玩转mysql和Nodejs连接Mysql 这俩篇文章 - 对Nest语法不熟悉的掘友可以看下Nest文档和Midway文档,
搭配服用
效果更佳
转载自:https://juejin.cn/post/7188120222530797605