nest-js

464 阅读6分钟

跟着后盾人大叔学一哈后端框架,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 推荐代码片段管理工具

massCode

创建数据库

初始化数据库

npx prisma init

tips 这里用的是prisma数据库

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
}

填充数据

  1. 首先执行 npx prisma migrate dev 生成prismaClient 来到navicat看一眼 数据表已经有了

1678954356045.png 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();
  1. 在 package.json 中创建执行脚本

1678960780161.png 4. 执行 npx prisma db seed 填充数据

  1. 刷新下 user表 可以看到数据插入成功了

1678961011317.png

创建auth模块

nest g co auth --no-spec // 控制器
nest g s auth --no-spec  // 服务
nest g mo auth           // 模块

建议设置终端可以自动补全,参考下面文章

终端美化及补全代码

删除无用文件后目录如下

1678974422528.png

搭建函数

// 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创建接口就不说了,测试通过

1678977492227.png

添加dto的类型及校验

// src/auth/dto/register.dto.ts
export class RegisterDto {
  @IsNotEmpty({ message: '用户名不能为空' })
  name: string;
  @IsNotEmpty({ message: '密码不能为空' })
  password: string;
}

推荐插件 创建文件/文件夹非常方便,如果你也不用鼠标的话

1678978052410.png 在controller中使用dto


  @Post('register')
  register(@Body() dto: RegisterDto) {
    return this.auth.register(dto);
  }

使用内置对象ValidationPipe在main.ts中对错误进行拦截

 app.useGlobalPipes(new ValidationPipe());

测试结果

1678978267543.png

自定义错误验证规则

虽然使用内置的验证规则比较方便,但是有些情况下也需要自定义验证规则,下面的这个是检测某个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;

测试结果

1678979214350.png

规范错误消息

通常前后端对错误消息的返回形式有一定的通用规则,所以在这里暂且放弃内置的验证管道,改用自定义管道,对错误消息的返回形式进行处理

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());

测试结果

1679019248853.png

完善注册模块

添加确认密码

新建 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 要与装饰器中的名字保持一致

测试结果

1679022272415.png

添加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),
      },
    });
  }
}

测试结果、添加成功 1679028771870.png

1679028751705.png

添加token

为了保证访问接口的安全性,当我们注册成功后,要给前端返回一个token,用来做权限验证

[token的介绍](傻傻分不清之 Cookie、Session、Token、JWT - 掘金 (juejin.cn))

在项目中我们使用jwt对token进行加密验权操作

  1. 首先在需要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 {}
  1. 在 .env中定义token加密的环境变量
// .env
TOKEN_SECRET='secret' 
  1. 注册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());

测试结果

1679037137692.png

记得在apifox的根目录下添加全局变量token,使得下次访问接口时,带着token验权访问

1679037334970.png

测试结果

1679037396406.png

创建登录服务

和注册步骤一样

  1. 创建路由

      @Post('login')
      login(@Body() dto: LoginDto) {
        return this.auth.login(dto);
      }
    
  2. 创建服务

      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);
      }
    
  3. 创建dto验证规则

    export class LoginDto {
      @IsNotEmpty({ message: '用户名不能为空' })
      @IsExists('user', { message: '用户名不存在' })
      name: string;
      @IsNotEmpty({ message: '密码不能为空' })
      password: string;
    }
    

    测试结果

1679042992930.png

设置路径别名

// 在 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

  1. 引入prismaService服务,调用findMany Api

  2. 设置分页 ,需要定义全局变量 ARTICLE_ROW ,也就是每页展示的数量

  3. 调用全局变量,必然要在app.module中引入ConfigModule

  4. 修改拦截器,将分页放在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;
  }

测试结果

1679228997229.png

create

  create(createArticleDto: CreateArticleDto) {
    return this.prisma.article.create({
      data: {
        title: createArticleDto.title,
        content: createArticleDto.content,
      },
    });
  }

// 记得同步更新自己需要的 CreateArticleDto 不再赘述

测试结果

1679229512122.png

delete

  async remove(id: number) {
    const article = await this.prisma.article.delete({
      where: {
        id,
      },
    });
    return article;
  }

测试结果

1679232258721.png

update

    return this.prisma.article.update({
      where: {
        id,
      },
      data: updateArticleDto,
    });

测试结果

1679233263734.png

路径中的article_id是我们添加的公共脚本中的变量

1679233449879.png 然后在需要id的地方添加前置变量

1679233617913.png

apifox我也不太熟悉,自行搜索

自动化测试一下

1679233920459.png

good!

添加请求前缀

app.setGlobalPrefix('api');

在apifox的请求路径中添加上api

1679267597163.png

新建目录模块

创建目录模型

// 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

会发现数据库中已经有数据了

再来一套增删改查

  1. 初始化栏目资源
nest g res category --no-spec
  1. 更新 dto
export class CreateCategoryDto {
  @IsNotEmpty({ message: '栏目名称不能为空' })
  title: string;
}

  1. 修改服务
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,
      },
    });
  }
  1. 去 apifox 把前面的文章接口复制一份,修改一下
  2. 启动项目,访问一下。==报错了!!!!!==

修改文章接口

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)
});

测试结果

1679469530804.png

生成在线接口文档

添加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);
  }

测试结果

1679473168342.png

创建聚合装饰器

// 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 

生成目录

1679476583117.png

创建角色验证

// 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' });