nestjs 个人学习过程总结

588 阅读9分钟

Typeorm prisma swagger

路由

全局路由前缀

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api'); // 设置全局路由前缀
  await app.listen(9080);
}
bootstrap();

创建文件

nest g [文件类型] [文件名] [文件目录]
  • 创建模块

nest g mo posts

  • 创建控制器

nest g co posts

  • 创建服务类

nest g service posts

注意创建顺序: 先创建Module, 再创建ControllerService, 这样创建出来的文件在Module中自动注册,反之,后创建Module, ControllerService,会被注册到外层的app.module.ts

生成某个 Module 的代码

nest g resource xxx

image.png

生成的代码就是带有 Controller、Service、Module 的,并且也有了 CRUD 的样板代码。

我们重点来看下 Controller 的代码:

import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { XxxService } from './xxx.service';
import { CreateXxxDto } from './dto/create-xxx.dto';
import { UpdateXxxDto } from './dto/update-xxx.dto';

@Controller('xxx')
export class XxxController {
  constructor(private readonly xxxService: XxxService) {}

  @Post()
  create(@Body() createXxxDto: CreateXxxDto) {
    return this.xxxService.create(createXxxDto);
  }

  @Get()
  findAll() {
    return this.xxxService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.xxxService.findOne(+id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateXxxDto: UpdateXxxDto) {
    return this.xxxService.update(+id, updateXxxDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.xxxService.remove(+id);
  }
}

@Controller 的参数可以声明 URL 路径,@Get、@Post、@Patch、@Delete 也可以通过参数声明 URL 路径,最终会把两个拼起来。比如 /xxx/:id 的 get 方法。

@Get、@Post、@Patch、@Delete 分别对应不同的请求方式。

@Param 是取路径中的参数,@Query 是取查询字符串的参数。

@Body 是把请求参数设置到对象的属性上,被用来传递数据的对象叫做 dto(data transfer object)。

再就是返回的对象会被序列化成 JSON,不需要手动序列化。

然后再看下 Service:

import { Injectable } from '@nestjs/common';
import { CreateXxxDto } from './dto/create-xxx.dto';
import { UpdateXxxDto } from './dto/update-xxx.dto';

@Injectable()
export class XxxService {
  create(createXxxDto: CreateXxxDto) {
    return 'This action adds a new xxx';
  }

  findAll() {
    return `This action returns all xxx`;
  }

  findOne(id: number) {
    return `This action returns a #${id} xxx`;
  }

  update(id: number, updateXxxDto: UpdateXxxDto) {
    return `This action updates a #${id} xxx`;
  }

  remove(id: number) {
    return `This action removes a #${id} xxx`;
  }
}

这些 service 的方法都没有具体实现。

装饰器

提供了 Controller、Service 等划分,这是对 MVC 模式的实现

Controller 里面负责处理请求,把处理过的参数传递给 service。

Service 负责业务逻辑的实现,基于 Typeorm 的增删改查功能来实现各种上层业务逻辑。

除此以外,Nest.js 还划分了 Module,这个 Module 是逻辑上的模块,和我们常说的文件对应的模块不同,它包含了 Controller、Service 等,是对这些资源的逻辑划分。

image.png

Module 和 Module 之间还可以有依赖关系,也就有 imports 和 exports。

所以,模块的声明就是这样的:

import { Module } from '@nestjs/common';

@Module({
  imports: [AaaModule],
  controllers: [BbbController],
  providers: [BbbService],
  exports: [BbbService]
})
export class BbbModule {}

这里通过 @Module 的装饰器来声明了 Bbb 的模块,它依赖了 Aaa 模块,也就是在 imports 引入的 AaaModule。controllers 是控制器,包含 BbbController,providers 是提供商,有 service、factory 等类型,这里包含 BbbService,同时,还导出了 BbbService 可以被其他模块引入。

Controller 的声明也是通过装饰器:

@Controller()
export class BbbController {
}

Service 的声明也是用装饰器,只不过不叫 Service,而叫 Injectable。

@Injectable()
export class BbbService {
}

至于为什么叫 Injectable,就涉及到了 IOC 的概念了。

IOC(Inverse Of Control)是控制反转的意思,是依赖注入,也就是 Controller、Service、Repository 等实例都在 IOC 容器内可以自动注入,只需要声明依赖,不需要手动 new。

因为所有的对象都是由容器管理的,那么自然就可以在创建对象的时候注入它需要的依赖,这就是 IOC 的原理。

Service 是可以被作为依赖注入到其他类的实例中去的,所以用 Injectable 装饰器。

所有的 Module 会有一个根 Module 作为入口,启动 IOC 容器就是从这个模块开始的:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import "reflect-metadata";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

上面就是典型的 Nest.js 启动代码,从 AppModule 这个根 Module 开始创建 IOC 容器,处理从 3000 端口发过来的请求。

reflect-metadata 模块是用于解析类的装饰器的,因为要给某个类的实例注入依赖就得能解析出它通过装饰器声明了哪些依赖,然后注入给它。所以要实现 IOC 需要依赖这个包。

这就是 Nest.js 大概的设计了:IOC + MVC,通过 IOC 容器来管理对象的依赖关系,通过 Controller、Service、Module 来做职责上的划分。

连接Mysql

TypeORM连接数据库

我们如果直接使用Node.js操作mysql提供的接口, 那么编写的代码就比较底层, 例如一个插入数据代码:

// 向数据库中插入数据 
connection.query(`INSERT INTO posts (title, content) VALUES ('${title}', '${content}')`,
    (err, data) => {
    if (err) { 
    console.error(err) 
    } else {
    console.log(data) 
    }
})

考虑到数据库表是一个二维表,包含多行多列,例如一个posts的表:

mysql> select * from posts;
+----+--------+------------+
| id | title       | content      |
+----+-------------+--------------+
|  1 | Nest.js入门 | 文章内容描述 |
+----+--------+------------+

每一行可以用一个JavaScript对象来表示, 比如第一行:

{
    id: 1,
    title:"Nest.js入门",
    content:"文章内容描述"
}

这就是传说中的ORM技术(Object-Relational Mapping),把关系数据库的变结构映射到对象上。

所以就出现了SequelizetypeORMPrisma这些ORM框架来做这个转换, (ps:Prisma呼声很高,喜欢探索的可以尝试婴一下)我们这里选择typeORM来操作数据库。 这样我们读写都是JavaScript对象,比如上面的插入语句就可以这样实现:

await connection.getRepository(Posts).save({title:"Nest.js入门", content:"文章内容描述"});

接下来就是真正意义上的使用typeORM操作数据库, 首先我们要安装以下依赖包:

npm install @nestjs/typeorm typeorm mysql2 -S

使用环境变量, 推荐使用官方提供的@nestjs/config,开箱即用

@nestjs/config依赖于dotenv,可以通过key=value形式配置环境变量,项目会默认加载根目录下的.env文件,我们只需在app.module.ts中引入ConfigModule,使用ConfigModule.forRoot()方法即可,然后ConfigService读取相关的配置变量。

首先在项目根目录下创建.env存的是环境变量,为了安全性考虑,建议这个文件添加到.gitignore中。:

// 数据库地址
DB_HOST=localhost  
// 数据库端口
DB_PORT=3306
// 数据库登录名
DB_USER=root
// 数据库登录密码
DB_PASSWD=root
// 数据库名字
DB_DATABASE=blog

创建数据库公用模块 @libs

nest g lib db 

/libs/db/src/db.module.ts 链接数据库

entities中引入数据表实体

@Global() // 标记为全局
@Module({
  imports: [
    // ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        type: 'mysql',
        host: config.get('DB_HOST'),
        port: config.get('DB_PORT'),
        database: config.get('DB_DATABASES'),
        username: config.get('DB_NAME'),
        password: config.get('DB_PASSWORD'),
        logging: config.get('DB_LOGGING'),
        synchronize: config.get('DB_SYNC'),
        entities: [],
        // entities: [User, Auth, Integral, Goods, Classify, Brand],
        timezone: '+08:00',
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [DbService],
  exports: [DbService],
})

项目引入数据库模块 /apps/admin/src/admin.module.ts imports 增加 DbModdule

entities的三种设置方式

方式1: 单独定义

TypeOrmModule.forRoot({
  //...
  entities: [PostsEntity, UserEntity],
}),]

就是用到哪些实体, 就逐一的在连接数据库时去导入,缺点就是麻烦,很容易忘记~

方式2:自动加载

 TypeOrmModule.forRoot({
  //...
  autoLoadEntities: true,
}),]

自动加载我们的实体,每个通过forFeature()注册的实体都会自动添加到配置对象的entities数组中, forFeature()就是在某个service中的imports里面引入的, 这个是我个人比较推荐的,实际开发我用的也是这种方式。

forFeature 用于创建不同实体类对应的 Repository,在用到该实体的 Module 里引入。

@Module({
  imports: [TypeOrmModule.forFeature([Aaa])],
  controllers: [AaaController],
  providers: [AaaService],
  exports: [AaaService]
})
export class AaaModule {}

方式3:自动加载

 TypeOrmModule.forRoot({
      //...
      entities: ['dist/**/*.entity{.ts,.js}'],
    }),]

通过配置的路径, 自动去导入实体。

Prisma

全局安装@prisma/cli

npm install prisma -g

初始化 Prisma项目

prisma init

修改数据库配置.env中DATABASE_URL

prisma/schema.prisma 中定义数据库结构

使用Prisma Migrate创建数据库表prisma/schema.prisma

要将数据模型映射到数据库架构

npx prisma migrate dev --name init 

打开web prisma浏览器桌面版

npx prisma studio

每当你对数据库进行了映射在 Prisma schema 的更改时,都需要手动重新生成 Prisma Client,以更新 node_modules/.prisma/client 目录中生成的代码:

npx prisma generate
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const newUser = await prisma.user.create({
  data: {
    name: 'Alice',
    email: 'alice@prisma.io',
  },
})

const users = await prisma.user.findMany()

接口格式统一

一般开发中是不会根据HTTP状态码来判断接口成功与失败的, 而是会根据请求返回的数据,里面加上code字段

首先定义返回的json格式:

{
    "code": 0,
    "message": "OK",
    "data": {
    }
}

请求失败时返回:

{
    "code": -1,
    "message": "error reason",
    "data": {}
}

拦截错误请求

首先使用命令创建一个过滤器:

nest g filter core/filter/http-exception

过滤器代码实现:

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(); // 获取请求上下文中的 response对象
    const status = exception.getStatus(); // 获取异常状态码

    // 设置错误信息
    const message = exception.message
      ? exception.message
      : `${status >= 500 ? 'Service Error' : 'Client Error'}`;
    const errorResponse = {
      data: {},
      message: message,
      code: -1,
    };

    // 设置返回的状态码, 请求头,发送错误信息
    response.status(status);
    response.header('Content-Type', 'application/json; charset=utf-8');
    response.send(errorResponse);
  }
}

最后需要在main.ts中全局注册

...
import { TransformInterceptor } from './core/interceptor/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  ...
   // 注册全局错误的过滤器
  app.useGlobalInterceptors(new TransformInterceptor());
  await app.listen(9080);
}
bootstrap();

这样对请求错误就可以统一的返回了,返回请求错误只需要抛出异常即可,比如之前的:

 throw new HttpException('文章已存在', 401);

接下来对请求成功返回的格式进行统一的处理,可以用Nest.js的拦截器来实现。

拦截成功的返回数据

首先使用命令创建一个拦截器:

nest g interceptor core/interceptor/transform
import {CallHandler, ExecutionContext, Injectable,NestInterceptor,} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        return {
          data,
          code: 0,
          msg: '请求成功',
        };
      }),
    );
  }
}

最后和过滤器一样,在main.ts中全局注册:

...
import { TransformInterceptor } from './core/interceptor/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  ...
  // 全局注册拦截器
 app.useGlobalInterceptors(new TransformInterceptor())
  await app.listen(9080);
}
bootstrap();

过滤器和拦截器实现都是三部曲:创建 > 实现 > 注册,还是很简单的。

配置接口文档Swagger

首先安装一下:

npm install @nestjs/swagger swagger-ui-express -S

接下来需要在main.ts中设置Swagger文档信息:

...
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  ...
  // 设置swagger文档
  const config = new DocumentBuilder()
    .setTitle('管理后台')   
    .setDescription('管理后台接口文档')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document);

  await app.listen(9080);
}
bootstrap();

接口标签

我们可以根据Controller来分类, 只要添加@ApiTags就可以

...
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';

@ApiTags("文章")
@Controller('post')
export class PostsController {...}

接参

1、url param /api/person/1

@Controller('api/person')
export class PersonController {
  @Get(':id')
  urlParm(@Param('id') id: string) {
    return `received: id=${id}`;
  }
}

2、query /api/person/find?name=ggso&age=18

@Controller('api/person')
export class PersonController {
  @Get('find')
  query(@Query('name') name: string, @Query('age') age: number) {
    return `received: name=${name},age=${age}`;
  }
}

3、html urlencoded

export class CreatePersonDto {
    name: string;
    age: number;
}
import { CreatePersonDto } from './dto/create-person.dto';

@Controller('api/person')
export class PersonController {
  @Post()
  body(@Body() createPersonDto: CreatePersonDto) {
    return `received: ${JSON.stringify(createPersonDto)}`
  }
}

前端代码使用 post 方式请求,指定 content type 为 application/x-www-form-urlencoded,用 qs 做下 url encode:

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
    <script src="https://unpkg.com/qs@6.10.2/dist/qs.js"></script>
</head>
<body>
    <script>
        async function formUrlEncoded() {
            const res = await axios.post('/api/person', Qs.stringify({
                name: '光',
                age: 20
            }), {
                headers: { 'content-type': 'application/x-www-form-urlencoded' }
            });
            console.log(res);  
        }

        formUrlEncoded();
    </script>
</body>
</html>

4、json 需要指定 content-type 为 application/json,内容会以 JSON 的方式传输:

@Controller('api/person')
export class PersonController {
  @Post()
  body(@Body() createPersonDto: CreatePersonDto) {
    return `received: ${JSON.stringify(createPersonDto)}`
  }
}

5、form data Nest.js 解析 form data 使用 FilesInterceptor 的拦截器,用 @UseInterceptors 装饰器启用,然后通过 @UploadedFiles 来取。非文件的内容,同样是通过 @Body 来取。

import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { CreatePersonDto } from './dto/create-person.dto';

@Controller('api/person')
export class PersonController {
  @Post('file')
  @UseInterceptors(AnyFilesInterceptor())
  body2(@Body() createPersonDto: CreatePersonDto, @UploadedFiles() files: Array<Express.Multer.File>) {
    console.log(files);
    return `received: ${JSON.stringify(createPersonDto)}`
  }
}

接口说明

进一步优化文档, 给每一个接口添加说明文字, 让使用的人直观的看到每个接口的含义,不要让使用的人去猜。同样在Controller中, 在每一个路由的前面使用@ApiOperation装饰器:

//  posts.controller.ts
...
import { ApiTags,ApiOperation } from '@nestjs/swagger';
export class PostsController {

  @ApiOperation({ summary: '创建文章' })
  @Post()
  async create(@Body() post) {....}
  
  @ApiOperation({ summary: '获取文章列表' })
  @Get()
  async findAll(@Query() query): Promise<PostsRo> {...}
  ....
}

接口传参

最后我们要处理的就是接口参数说明, Swagger的优势之一就是,只要注解到位,可以精确展示每个字段的意义,我们想要对每个传入的参数进行说明。

数据传输对象(DTO)(Data Transfer Object),是一种设计模式之间传输数据的软件应用系统。数据传输目标往往是数据访问对象从数据库中检索数据。数据传输对象与数据交互对象或数据访问对象之间的差异是一个以不具有任何行为除了存储和检索的数据(访问和存取器)。

这一段是官方解释, 看不懂没关系,可以理解成,DTO 本身更像是一个指南, 在使用API时,方便我们了解请求期望的数据类型以及返回的数据对象。先使用一下,可能更方便理解。

posts目录下创建一个dto文件夹,再创建一个create-post.dot.ts文件:

// dto/create-post.dot.ts
export class CreatePostDto {
  readonly title: string;
  readonly author: string;
  readonly content: string;
  readonly cover_url: string;
  readonly type: number;
}

然后在Controller中对创建文章是传入的参数进行类型说明:

//  posts.controller.ts
...
import { CreatePostDto } from './dto/create-post.dto';

@ApiOperation({ summary: '创建文章' })
@Post()
async create(@Body() post:CreatePostDto) {...}

这里提出两个问题:

  1. 为什么不使用 interface 而要使用 class 来声明 CreatePostDto
  2. 为什么不直接用之前定义的实体类型PostsEntiry,而是又定义一个 CreatePostDto

对于第一个问题,我们都知道Typescript接口在编译过程中是被删除的,其次后面我们要给参数加说明,使用Swagger的装饰器,interface也是无法实现的,比如:

import { ApiProperty } from '@nestjs/swagger';

export class CreatePostDto {
  @ApiProperty({ description: '文章标题' })
  readonly title: string;

  @ApiProperty({ description: '作者' })
  readonly author: string;

  @ApiPropertyOptional({ description: '内容' })
  readonly content: string;

  @ApiPropertyOptional({ description: '文章封面' })
  readonly cover_url: string;

  @ApiProperty({ description: '文章类型' })
  readonly type: number;
}

对于上面提到的第二个问题,为什么不直接使用实体类型PostsEntiry,而是又定义一个 CreatePostDto,因为HTTP请求传参和返回的内容可以采用和数据库中保存的内容不同的格式,所以将它们分开可以随着时间的推移及业务变更带来更大的灵活性,这里涉及到单一设计的原则,因为每一个类应该处理一件事,最好只处理一件事。

现在就可以从API文档上直观的看到每个传参的含义、类型以及是否必传。到这一步并没有完, 虽然以及告诉别人怎么传, 但是一不小心传错了呢, 比如上面作者字段没传,会发生什么呢?

接口直接报500了, 因为我们实体定义的author字段不能为空的,所有在写入数据时报错了。这样体验非常不好, 很可能前端就怀疑我们接口写错了,所有我们应该对异常进行一定的处理。

数据验证

怎么实现呢?首先想到的是在业务中去写一堆的if-elese判断用户的传参,一想到一堆的判断, 这绝对不是明智之举,所有我去查了Nest.js中数据验证,发现Nest.js中的管道就是专门用来做数据转换的,我们看一下它的定义:

管道是具有 @Injectable() 装饰器的类。管道应实现 PipeTransform 接口。

管道有两个类型:

  • 转换:管道将输入数据转换为所需的数据输出
  • 验证:对输入数据进行验证,如果验证成功继续传递; 验证失败则抛出异常;

image.png

管道在异常区域内运行。这意味着当抛出异常时,它们由核心异常处理程序和应用于当前上下文的 异常过滤器 处理。当在 Pipe 中发生异常,controller 不会继续执行任何方法。

什么意思呢, 通俗来讲就是,对请求接口的入参进行验证和转换的前置操作,验证好了我才会将内容给到路由对应的方法中去,失败了就进入异常过滤器中。

Nest.js自带了三个开箱即用的管道:ValidationPipeParseIntPipeParseUUIDPipe, 其中ValidationPipe 配合class-validator就可以完美的实现我们想要的效果(对参数类型进行验证,验证失败抛出异常)。

管道验证操作通常用在dto这种传输层的文件中,用作验证操作。首先我们安装两个需要的依赖包:class-transformerclass-validator

npm install class-validator class-transformer -S

然后在create-post.dto.ts文件中添加验证, 完善错误信息提示:

import { IsNotEmpty, IsNumber, IsString } from 'class-validator';

export class CreatePostDto {
  @ApiProperty({ description: '文章标题' })
  @IsNotEmpty({ message: '文章标题必填' })
  readonly title: string;

  @IsNotEmpty({ message: '缺少作者信息' })
  @ApiProperty({ description: '作者' })
  readonly author: string;

  @ApiPropertyOptional({ description: '内容' })
  readonly content: string;

  @ApiPropertyOptional({ description: '文章封面' })
  readonly cover_url: string;

  @IsNumber()
  @ApiProperty({ description: '文章类型' })
  readonly type: number;
}

入门阶段,我们使用的数据比较简单,上面只编写了一些常用的验证,class-validator还提供了很多的验证方法, 大家感兴趣可以自己看官方文档

最后我们还有一个重要的步骤, 就是在main.ts中全局注册一下管道ValidationPipe

app.useGlobalPipes(new ValidationPipe());

此时我们在发送一个创建文章请求,不带author参数, 返回数据有很清晰了:

image.png

通过上边的学习,可以知道DTO本身是不存在任何验证功能, 但是我们可以借助class-validator来让DTO可以验证数据

jwt Redis

juejin.cn/post/685457… juejin.cn/post/684490…

日志

juejin.cn/post/684490…

管道

juejin.cn/post/684490…

用户注册

总结聚合

Typeorm 是一个 ORM 框架,通过映射表和对象的对应关系,就可以把对对象的操作转换为对数据库的操作,自动执行 sql 语句。

Nest.js 是一个 MVC 框架,提供了 Module、Controller、Service 的逻辑划分,也实现了 IOC 模式,集中管理对象和自动注入依赖。

Typeorm 和 Nest.js 的结合使用 @nestjs/typeorm 的包,它提供了一个 TypeormModule 的模块,有 forRoot 和 forFeature 两个静态方法。forRoot 方法用于生成连接数据库的 Module,forFeature 用于生成实体对应的 Repository 的 Module。

Nest.js 有很多样板代码,可以用 @nestjs/cli 的命令行工具生成,包括整体的和每个 Module 的。

总之,理解了 IOC,理解了 Module、Controller、Service 的划分,就算是初步掌握了 Nest.js,结合 Typeorm 的 ORM 框架可以轻松的做数据库表的 CRUD。

教程参考