跟着后盾人大叔学一哈后端框架,nestjs。 大叔写了个小博客。 跟着敲完了后端的接口。
Blog
准备工作
创建nest项目
nest new nest-blog
安装包
安装生产包
pnpm add prisma-binding @prisma/client mockjs @nestjs/config class-validator class-transformer argon2 @nestjs/passport passport passport-local @nestjs/jwt passport-jwt lodash multer dayjs express redis @nestjs/throttler
安装开发包
pnpm add -D prisma typescript @types/node @types/mockjs @nestjs/mapped-types @types/passport-local @types/passport-jwt @types/express @types/lodash @types/multer @types/node
tips 推荐代码片段管理工具
创建数据库
初始化数据库
npx prisma init
tips 这里用的是prisma数据库
连接数据库
// .env
DATABASE_URL="mysql://root:123456@127.0.0.1:3306/nest-blog"
修改数据库类型
// schema.prisma
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
创建数据模型
// schema.prisma
model user {
id Int @id @default(autoincrement()) @db.UnsignedInt()
name String @unique
password String
}
model article {
id Int @id @default(autoincrement()) @db.UnsignedInt()
title String
content String @db.Text
}
填充数据
- 首先执行
npx prisma migrate dev生成prismaClient 来到navicat看一眼 数据表已经有了
2. 新建seed.ts 文件 填充数据
import { PrismaClient } from '@prisma/client';
import { Random } from 'mockjs';
import { hash } from 'argon2';
const prisma = new PrismaClient();
async function run() {
await prisma.user.create({
data: {
name: 'secret',
password: await hash('123456'),
},
});
for (let i = 0; i < 50; i++) {
await prisma.article.create({
data: {
title: Random.title(2, 8),
content: Random.paragraph(50),
},
});
}
}
run();
- 在 package.json 中创建执行脚本
4. 执行
npx prisma db seed 填充数据
- 刷新下 user表 可以看到数据插入成功了
创建auth模块
nest g co auth --no-spec // 控制器
nest g s auth --no-spec // 服务
nest g mo auth // 模块
建议设置终端可以自动补全,参考下面文章
删除无用文件后目录如下
搭建函数
// auth.service.ts
@Injectable()
export class AuthService {
register(dto) {
return dto;
}
}
// auth.controller.ts
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
register(@Body() dto: any) {
return this.auth.register(dto);
}
}
// auth.module.ts
@Module({
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
测试服务
使用Apifox测试接口,apifox创建接口就不说了,测试通过
添加dto的类型及校验
// src/auth/dto/register.dto.ts
export class RegisterDto {
@IsNotEmpty({ message: '用户名不能为空' })
name: string;
@IsNotEmpty({ message: '密码不能为空' })
password: string;
}
推荐插件 创建文件/文件夹非常方便,如果你也不用鼠标的话
在controller中使用dto
@Post('register')
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
使用内置对象ValidationPipe在main.ts中对错误进行拦截
app.useGlobalPipes(new ValidationPipe());
测试结果
自定义错误验证规则
虽然使用内置的验证规则比较方便,但是有些情况下也需要自定义验证规则,下面的这个是检测某个value值是否存在,毕竟相同的名字不能注册两遍。
// src/common/rules/is-not-exists.ts
// 函数装饰器
export function IsNotExists(
table: string,
validationOptions?: ValidationOptions,
) {
return function (object: Record<string, any>, propertyName: string) {
registerDecorator({
name: 'IsNotExists',
target: object.constructor,
propertyName: propertyName,
constraints: [table],
options: validationOptions,
validator: {
async validate(value: string, args: ValidationArguments) {
const prisma = new PrismaClient();
const res = await prisma[table].findFirst({
where: {
[args.property]: value,
},
});
return !Boolean(res);
},
},
});
};
}
轮子无需重复造,放置在代码片段中,粘过来用即可
在dto中使用
@IsNotEmpty({ message: '用户名不能为空' })
@IsNotExists('user', { message: '用户名已经存在' })
name: string;
测试结果
规范错误消息
通常前后端对错误消息的返回形式有一定的通用规则,所以在这里暂且放弃内置的验证管道,改用自定义管道,对错误消息的返回形式进行处理
export default class Validate extends ValidationPipe {
protected flattenValidationErrors(
validationErrors: ValidationError[],
): string[] {
const message = {};
validationErrors.forEach((error) => {
message[error.property] = Object.values(error.constraints)[0];
});
throw new HttpException(
{
code: 422,
message,
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
记得在main.ts中也要作相应的修改
app.useGlobalPipes(new Validate());
测试结果
完善注册模块
添加确认密码
新建 is-confirm 验证规则
// src/common/rules/is-confirmed.ts
// 验证类装饰器
@ValidatorConstraint({ name: 'IsConfirmed' })
export class IsConfirmed implements ValidatorConstraintInterface {
validate(value: any, args?: ValidationArguments): boolean | Promise<boolean> {
return value == args.object[`${args.property}_confirmation`];
}
defaultMessage?(validationArguments?: ValidationArguments): string {
return '比对失败';
}
}
dto 调用
@IsNotEmpty({ message: '密码不能为空' })
@Validate(IsConfirmed, { message: '两次密码不一致' })
password: string;
@IsNotEmpty({ message: '密码不能为空' })
password_confirmation: string;
注意: 类装饰的使用规则 以及 password_confirmation 要与装饰器中的名字保持一致
测试结果
添加prisma模块
nest g mo prisma
nest g s prisma --no-spec
将prisma模块定义为全局模块,并且导出service
// src/prisma/prisma.module.ts
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
prismaService 继承 prismaClient属性
// src/prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient {}
创建注册服务
@Injectable()
export class AuthService {
constructor(private readonly prisma: PrismaService) {}
async register(dto: RegisterDto) {
const user = this.prisma.user.create({
data: {
name: dto.name,
password: await hash(dto.password),
},
});
}
}
测试结果、添加成功
添加token
为了保证访问接口的安全性,当我们注册成功后,要给前端返回一个token,用来做权限验证
[token的介绍](傻傻分不清之 Cookie、Session、Token、JWT - 掘金 (juejin.cn))
在项目中我们使用jwt对token进行加密验权操作
- 首先在需要jwt的模块中引入jwt模块
@Module({
imports: [
JwtModule.registerAsync({
// 由于需要全局环境变量 TOKEN_SECRET 所以我们引入Config模块供Jwt模块使用
imports: [ConfigModule],
inject: [ConfigService],
useFactory(config: ConfigService) {
return {
// 加密签名
secret: config.get('TOKEN_SECRET'),
// 过期时间
signOptions: { expiresIn: '100d' },
};
},
}),
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
- 在 .env中定义token加密的环境变量
// .env
TOKEN_SECRET='secret'
-
注册jwtService 服务,创建token函数
// src/auth/auth.service.ts @Injectable() export class AuthService { constructor( private readonly jwt: JwtService, ) {} async register(dto: RegisterDto) { // 注册操作,上文已写 // 、、、 // 、、、 return this.token(user); } // 根据用户名和id生成token async token({ name, id }: user) { return { token: await this.jwt.signAsync({ name, sub: id }), }; } }
添加消息拦截器
后端返回给前端的数据要遵从一定的数据格式,方便前后端联调,在此用拦截器封装一下数据再返回给前端
// src/transform.interceptors.ts
@Injectable()
export class TransformInterceptors implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest() as Request;
const startTime = Date.now();
return next.handle().pipe(
map((data) => {
const endTime = Date.now();
new Logger().log(
`TIME:${endTime - startTime}\tURL:${request.path}\tMETHOD:${
request.method
}`,
);
return {
data,
};
}),
);
}
}
在main.ts中添加全局拦截器
app.useGlobalInterceptors(new TransformInterceptors());
测试结果
记得在apifox的根目录下添加全局变量token,使得下次访问接口时,带着token验权访问
测试结果
创建登录服务
和注册步骤一样
-
创建路由
@Post('login') login(@Body() dto: LoginDto) { return this.auth.login(dto); } -
创建服务
async login(dto: LoginDto) { const user = await this.prisma.user.findUnique({ where: { name: dto.name, }, }); if (!(await verify(user.password, dto.password))) { throw new BadRequestException('密码输入错误'); } return this.token(user); } -
创建dto验证规则
export class LoginDto { @IsNotEmpty({ message: '用户名不能为空' }) @IsExists('user', { message: '用户名不存在' }) name: string; @IsNotEmpty({ message: '密码不能为空' }) password: string; }测试结果
设置路径别名
// 在 ts.config中添加
"paths":{
"@/*":["src/*"]
}
创建文章资源模块
nest g res article --no-spec
完善服务
这个资源模块会自动帮助我们生成增删改查
@Injectable()
export class ArticleService {
constructor(private readonly prisma: PrismaService) {}
create(createArticleDto: CreateArticleDto) {
return 'This action adds a new article';
}
findAll() {
return `This action returns all article`;
}
findOne(id: number) {
return `This action returns a #${id} article`;
}
update(id: number, updateArticleDto: UpdateArticleDto) {
return `This action updates a #${id} article`;
}
remove(id: number) {
return `This action removes a #${id} article`;
}
}
我们一条条来完善,首先完善findAll
findAll
-
引入prismaService服务,调用findMany Api
-
设置分页 ,需要定义全局变量 ARTICLE_ROW ,也就是每页展示的数量
-
调用全局变量,必然要在app.module中引入ConfigModule
-
修改拦截器,将分页放在data外面
下图展示了修改的地方
// article.service.ts async findAll(page = 1) { const row = this.config.get('ARTICLE_ROW'); const articles = await this.prisma.article.findMany({ skip: (page - 1) * row, take: +row, }); const total = await this.prisma.article.count(); return { meta: { curPage: page, pageRow: row, total, totalPage: total / row, }, data: articles, }; } // app.module.ts @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), ], })// .env ARTICLE_ROW='10' // transform.interceptors.ts export class TransformInterceptors implements NestInterceptor { // 、、、 // 、、、 return data?.meta ? data : { data }; }
find
async findOne(id: number) {
const article = await this.prisma.article.findFirst({
where: {
id,
},
});
return article;
}
测试结果
create
create(createArticleDto: CreateArticleDto) {
return this.prisma.article.create({
data: {
title: createArticleDto.title,
content: createArticleDto.content,
},
});
}
// 记得同步更新自己需要的 CreateArticleDto 不再赘述
测试结果
delete
async remove(id: number) {
const article = await this.prisma.article.delete({
where: {
id,
},
});
return article;
}
测试结果
update
return this.prisma.article.update({
where: {
id,
},
data: updateArticleDto,
});
测试结果
路径中的article_id是我们添加的公共脚本中的变量
然后在需要id的地方添加前置变量
apifox我也不太熟悉,自行搜索
自动化测试一下
good!
添加请求前缀
app.setGlobalPrefix('api');
在apifox的请求路径中添加上api
新建目录模块
创建目录模型
// prisma/schema.prisma
model category {
id Int @id @default(autoincrement()) @db.UnsignedInt()
title String
article article[]
}
// 文章和 article model 有关联关系 保存或者格式化的时候 article 会自动更新
// 更新后的article model 因为文章必须有category 所以我们手动删除 ? 将字段设置为必填字段
model article {
id Int @id @default(autoincrement()) @db.UnsignedInt()
title String
content String @db.Text
// 手动添加 删除 的级联关系
category category @relation(fields: [categoryId], references: [id],onDelete: Cascade)
categoryId Int @db.UnsignedInt()
}
在终端输入命令 npx prisma migrate dev 更新库表
会出问题,因为两个表有关联关系,但是不同步,建议把article表删了,重新执行 npx prisma migrate dev 更新库表
填充数据
// seed.ts
for (let i = 1; i < 6; i++) {
await prisma.category.create({
data: {
title: Random.title(2, 8),
},
});
}
// 如果 prisma 读不到 category ,就重启 vscode
// article 中 categoryId是必填字段 补充一下
for (let i = 0; i < 50; i++) {
await prisma.article.create({
data: {
categoryId: _.random(1, 5),
},
});
}
在tsConfig.json的compileOptions中添加 "esModuleInterop":true,
执行下 npx prisma migrate reset
会发现数据库中已经有数据了
再来一套增删改查
- 初始化栏目资源
nest g res category --no-spec
- 更新 dto
export class CreateCategoryDto {
@IsNotEmpty({ message: '栏目名称不能为空' })
title: string;
}
- 修改服务
create(createCategoryDto: CreateCategoryDto) {
return this.prisma.category.create({
data: createCategoryDto,
});
}
findAll() {
return this.prisma.category.findMany();
}
findOne(id: number) {
return this.prisma.category.findFirst({
where: {
id,
},
});
}
update(id: number, updateCategoryDto: UpdateCategoryDto) {
return this.prisma.category.update({
where: { id },
data: updateCategoryDto,
});
}
remove(id: number) {
return this.prisma.category.delete({
where: {
id,
},
});
}
- 去 apifox 把前面的文章接口复制一份,修改一下
- 启动项目,访问一下。==报错了!!!!!==
修改文章接口
ps: 因为我们在 文章 model 里面添加了 categoryId 字段,所以以前写的文章会报错
修改如下:
// dto
export class CreateArticleDto {
@IsNotEmpty({ message: '所属栏目Id不能为空' })
categoryId: string;
}
// service
create(createArticleDto: CreateArticleDto) {
return this.prisma.article.create({
data: {
// 、、、
// 、、、
categoryId: +createArticleDto.categoryId,
},
});
}
update(id: number, updateArticleDto: UpdateArticleDto) {
return this.prisma.article.update({
// 、、、
// 、、、
data: {
...updateArticleDto,
categoryId: +updateArticleDto.categoryId,
},
});
}
在 apifox 中修改相应的接口参数
完善栏目测试
添加公共脚本,在需要用到category_id的地方添加 前置ID
pm.sendRequest("http://localhost:3000/api/category", function (err, response) {
const categorys = response.json()
pm.environment.set('category_id', categorys.data[0].id)
});
测试结果
添加token鉴权
新建jwt服务
// src/auth/strategy/jwt.strategy.ts
import { PrismaService } from './../prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService, private prisma: PrismaService) {
super({
// 解析用户提交的Bearer Token header数据
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// 加密码的secret
secretOrKey: configService.get('TOKEN_SECRET'),
});
}
validate({ sub: id }) {
return this.prisma.user.findUnique({
where: {
id,
},
});
}
}
注册 Jwt 服务
// category.module.ts
providers: [CategoryService, JwtStrategy],
使用Jwt拦截验证
// 在需要鉴权的地方添加 jwt 拦截器
@Post()
@UseGuards(AuthGuard('jwt'))
create(@Body() createCategoryDto: CreateCategoryDto) {
return this.categoryService.create(createCategoryDto);
}
测试结果
创建聚合装饰器
// src/auth/decorators/auth.decorators.ts
export function Auth(...roles: Role[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(AuthGuard('jwt')),
);
}
// src/auth/enum.ts
export enum Role {
ADMIN = 'admin',
}
将 上述的 装饰器 @UseGuards(AuthGuard('jwt')) 替换成 聚合装饰器 @Auth()
增加角色验证
创建拦截器
这次我们选择用命令来创建
nest g gu auth/guards/role --no-spec
生成目录
创建角色验证
// role.guard.ts
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const user = context.switchToHttp().getRequest().user as user;
const roles = this.reflector.getAllAndMerge('roles', [
context.getHandler(),
context.getClass(),
]);
return roles.length ? roles.some((role) => role == user.role) : true;
}
}
注入拦截器
// auth.decorator.ts
UseGuards(AuthGuard('jwt'), RoleGuard),
添加角色验证
@Auth(Role.EDITOR)
创建上传模块
创建模块
// 还是那三个命令
nest g co upload --no-spec
nest g s upload --no-spec
nest g mo upload
导入Multer模块
// upload.module.ts
MulterModule.registerAsync({
useFactory() {
return {
storage: diskStorage({
destination: 'uploads',
filename: (req, file, callback) => {
const path =
Date.now() +
'-' +
Math.round(Math.random() * 1e10) +
extname(file.originalname);
callback(null, path);
},
}),
};
},
}),
创建 upload 工具函数
import {
applyDecorators,
UnsupportedMediaTypeException,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
//上传类型验证
export function filterFilter(type: string) {
return (
req: any,
file: Express.Multer.File,
callback: (error: Error | null, acceptFile: boolean) => void,
) => {
if (!file.mimetype.includes(type)) {
callback(new UnsupportedMediaTypeException('文件类型错误'), false);
} else {
callback(null, true);
}
};
}
//文件上传
export function Upload(field = 'file', options: MulterOptions) {
return applyDecorators(UseInterceptors(FileInterceptor(field, options)));
}
//图片上传
export function Image(field = 'file') {
return Upload(field, {
//上传文件大小限制
limits: Math.pow(1024, 2) * 2,
fileFilter: filterFilter('image'),
} as MulterOptions);
}
//文档上传
export function Document(field = 'file') {
return Upload(field, {
//上传文件大小限制
limits: Math.pow(1024, 2) * 5,
fileFilter: filterFilter('document'),
} as MulterOptions);
}
调用装饰器
@Controller('upload')
export class UploadController {
@Post('image')
@Image()
image(@UploadedFile() file: Express.Multer.File) {
return file;
}
}
添加静态访问路径
// main.ts
//静态资源访问
app.useStaticAssets('uploads', { prefix: '/uploads' });