我的Nest之路(一)- 接口的诞生

666 阅读12分钟

前言

由于最近工作需要进行后端开发,经过学习和了解,本次采用的是Nest + TypeORM + Typescript,记录一下第一个接口的诞生过程。作为一名前端er,第一次接触后端开发,如有疑问或者问题,还请大佬们指出,带带弟弟。

在开始之前,我们首先要了解Nest和TypeORM的概念。

Nest

[Nest 官网]

[Nest 中文文档]

什么是Nest,为什么选择Nest?

Nest是基于 Express(默认)或 Fastify 的Node框架。

至于我选择Nest的原因,一是因为官方支持Typescript,二是因为Nest在Github上star数较高,能收获那么多人的star,想必一定有它的优势。至于较其它框架有哪些优势,此处留个坑🕳,待我完完整整体验完整个项目后,有所感悟再来补上。

TypeORM

[TypeORM 文档]

看了官网介绍一大堆,我理解的TypeORM就是连接数据库的框架,它提供一系列方法供我们连接数据库和进行增删查改,这也是我们提供接口服务的基石。

下面我们就一步步来讲述一个接口的诞生。👏

接口的诞生

1.创建项目

我们首先按照官网提供的命令行,创建项目

npm i -g @nestjs/cli
nest new nest-demo

它会弹出选择,让你选择用npm或yarn或pnpm来管理项目的依赖,这里我使用的npm。

在等待一段时间后,你会得到如下项目。

然后按照提示启动项目(我这里用的start:dev,因为--watch命令会检测你的代码更新,自动重启项目,而不必每次重新启动)

cd nest-demo
npm run start:dev

2.第一个接口

在地址栏输入localhost:3000 (端口号可查看main.ts里app.listen),得到如下返回

为什么会得到Hello World!的返回呢?

我们首先找到 main.ts 可以看到这里只引用并创建了AppModule,那么返回一定和它相关。

顺着路径找上去,我们可以看到app.module.ts代码如下

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

我们从名称命名就可以大致了解到这里声明了一个模块,并且引用了一个 AppController 控制器处理逻辑,一个 AppService 提供基础服务,(imports和exports本期不说明)然后根据Module提供的方法进行关联起来。最终,我们也确实在 app.service.ts 里找到了getHello方法,并且在 app.controller.ts 进行了调用。

下面我们对代码进行改造,我们以每个项目都会需要的字典接口为例(后面都是以这个接口进行说明),先不连接数据库,经过改造后代码如下,访问localhost:3000后就会得到dicts的值。

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

type dictItem = {
  label: string;
  value: string;
};

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getDict(): dictItem[] {
    return this.appService.getDict();
  }
}
import { Injectable } from '@nestjs/common';

type dictItem = {
  label: string;
  value: string;
};

@Injectable()
export class AppService {
  getDict(): dictItem[] {
    const dicts = [
      {
        label: 'Nest',
        value: 'Nest',
      },
      {
        label: 'Express',
        value: 'Express',
      },
      {
        label: 'Fastify',
        value: 'Fastify',
      },
      {
        label: 'Egg',
        value: 'Egg',
      },
    ];

    return dicts;
  }
}

或许你会疑问为什么是请求了localhost:3000得到了dicts而不是localhost:3000/common/dict(或者其它路径)得到了返回呢?

这是因为我们并没有设置路径,框架会默认根路径。我们有两种方式可以设置路径,一个是设置模块的基路径(比如@Contoller('common'),即该模块下的接口都会带上这个common前缀),另一个是设置接口的路径(比如@Get('dict'),@Post('dict') ... 我这里用的Get)。修改后,我们访问localhost:3000/common/dict就能得到刚才那个dicts。

3.校验前端参数

字典接口通常会有一个入参,比如dictType,用来查询这个字典集合,那么如何获取到前端传来的参数呢?

......

@Controller('common')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('dict')
  getDict(@Query() query: object): dictItem[] {
    console.log(query)
    return this.appService.getDict();
  }
  
  ......
}

nest提供了一系列的参数装饰器,我们这里使用了@Query() 获取query参数,框架会自动帮我们处理成对象并赋值给query。我们这里仅需要了解@Param(),@Body(),@Query(),@Headers(),分别对应了path,body,query,header参数(这里引用了swagger的参数类型概念)。这四种装饰器基本可以满足绝大部分接口的需要,其它装饰器可参考[参数装饰器]

我们在postman请求localhost:3000/common/dict?dictType=node之后,可以看到打印的query下有个dictType字段,值为node。你可能会对dictType做逻辑判断,比如dictType不能为空,为空时直接返回错误,代码可能如下。

(BadRequestException是nest提供的抛出http 400错误的方法,其它的还有401 UnauthorizedException,404 NotFoundException,502 BadGatewayException等等,如果不知道http code对应的具体方法,可以直接使用HttpException,比如throw new HttpException('重定向', 301))

......
getDict(@Query() query: object): dictItem[] {
  if (query.dictType === '') {
    throw new BadRequestException('dictType 不能为空')
  }
  return this.appService.getDict();
}
......

目前来说可行,但是当接口逻辑复杂到一定程度时,这样的写法就会让你的代码看起来比较混乱,难以维护。好在官方提供了搭配class-validator对参数进行验证的方案。在介绍如何使用class-validator之前,我们需要先了解一下DTO的概念。

数据传输对象(DTO)(Data Transfer Object)

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

这段定义我没怎么看懂(感觉就跟数学定义一样,233),但我理解的是DTO就是一个数据传输的约定,在这里就相当于约定了我的接口会 接收哪些参数返回哪些值 。我们要做的接口验证就是在这里处理。

1.我们需要安装依赖

npm i --save class-validator class-transformer

2.安装好依赖之后,我们在同目录创建app.dto.ts,对出入参约定进行编写,并修改app.controller.ts和app.service.ts 参数对应的类型,代码如下:

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

export class getDictReq {
  @IsString()
  @IsNotEmpty({ message: 'dictType 不能为空' })
  readonly dictType: string;
}

export class getDictRes {
  readonly label: string;
  readonly value: string;
}
import { Injectable } from '@nestjs/common';
import * as DTO from './app.dto';

@Injectable()
export class AppService {
  getDict(dictType: string): DTO.getDictRes[] {
    const dicts = [
      {
        label: 'Nest',
        value: 'Nest',
      },
      {
        label: 'Express',
        value: 'Express',
      },
      {
        label: 'Fastify',
        value: 'Fastify',
      },
      {
        label: 'Egg',
        value: 'Egg',
      },
    ];

    return dictType === 'node' ? dicts : [];
  }
}
import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from './app.service';
import * as DTO from './app.dto';

@Controller('common')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('dict')
  getDict(@Query() query: DTO.getDictReq): DTO.getDictRes[] {
    return this.appService.getDict(query.dictType);
  }
}

可以看到我们引用了两个校验,一个是检查是否是字符串@IsString(),一个是检查是否是为空(!== '',!== null,!== undefined),具体其它检验方法可参见class-validator装饰器

3.到这一步,我们只是做好了约定,还没有生效。我们还需要创建一个validation.pipe.ts文件。为什么是pipe.ts而不是其它类型文件。因为按官网上管道的说明,管道主要做两个事情,一个是数据的转换,另一个是数据的验证,而这里就是对数据进行验证。

nest g pi validation pipe

nest g [文件类型] [文件名] [文件路径]

import {
  ArgumentMetadata,
  Injectable,
  PipeTransform,
  BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      // 如果没有传入验证规则,则不验证,直接返回数据
      return value;
    }
    // 将普通对象转换为类对象
    const object = plainToClass(metatype, value);
    const errors = await validate(object);

    if (errors.length > 0) {
      let msg = '';
      for (let i = 0; i < errors.length; i++) {
        // 只取第一层首个校验错误信息返回 对于深层级的错误不递归取 仅提示校验错误
        if (errors[i].constraints) {
          try {
            msg = Object.values(errors[i].constraints)[0];
          } catch (e) {
            msg = '';
          }
          break;
        }
      }
      throw new BadRequestException(`Validation failed: ${msg}`);
    }
    return value;
  }

  private toValidate(metatype: any): boolean {
    const types: any[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

metatype对应的就是body: DTO.getDictReq的DTO.getDictReq,即app.dto.ts的getDictReq,只有当controller里引用声明了类型才会去检查参数。

另外,这里我只取了第一层校验的错误,因为如果当我们使用@ValidateNested()搭配@Type()去校验子对象的参数时,要使用dto里设置的错误信息,需要递归去取,我认为没有必要,只需要返回校验失败就够了。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './pipe/validation.pipe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 管道 处理参数校验
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

最后全局调用,校验参数的目的就完成了。结果如下:

4.捕获错误异常,统一处理

在接口开发过程中,往往会存在很多错误异常,我们可能会需要对这些错误进行统一处理,那么如何实现呢?

同管道一样,我们需要创建一个异常过滤器(error.filter.ts),然后全局调用。修改过程如下:

nest g f error filter
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class ErrorFilter 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 : '服务异常';
    const errorResponse = {
      data: null,
      message: message,
      code: `${status}`,
    };

    console.log('code', status, message);
    // 设置返回的状态码, 请求头,发送错误信息
    response.status(status);
    response.header('Content-Type', 'application/json; charset=utf-8');
    response.send(errorResponse);
  }
}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './pipe/validation.pipe';
import { ErrorFilter } from './filter/error.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 管道 处理参数校验
  app.useGlobalPipes(new ValidationPipe());
  // 过滤器 处理http异常统一返回
  app.useGlobalFilters(new ErrorFilter());
  await app.listen(3000);
}
bootstrap();

当我们请求接口不传dictType时,触发了校验失败,在validation.pipe.ts中校验失败会抛出400 bad request异常,然后我们在error.filter.ts中捕获到了,并进行了统一处理,最后得到打印"code 400 Validation failed: dictType 不能为空"

5.设置统一返回

一般来说,接口都会有一个统一的数据格式,它可能是下面这个样子

{
  "code": "200",
  "message": "success",
  "data": [
    {
      "label": "Nest",
      "value": "Nest",
    },
    ...
  ]
}

nest提供的拦截器刚好可以完成统一设置。我们创建一个interface.interceptor.ts文件,代码如下:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class InterfaceInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        return {
          data,
          code: '200',
          message: 'success',
        };
      }),
    );
  }
}

它提供两个参数,context(上下文),next(回调)。next执行了handle之后才能拿到接口处理完的返回,然后通过.pipe()和map方法遍历返回数据进行统一格式的设置。注意这里只有正常返回时才会进行设置,抛出的异常会直接走filter的返回。

6.操作数据库

到目前为止,我们还没有涉及数据库,下面就介绍如何通过TypeORM连接数据库并进行操作。

1.安装依赖,我这里用的mysql数据库。

npm install @nestjs/typeorm typeorm mysql2 --save

2.编写你的实体。

实体是什么?

实体是一个映射到数据库表(或使用 MongoDB 时的集合)的类。

TypeORM就是通过实体知道要操作的是哪张表。还是以字典接口为例,在根路径下创建entity/dicts.entity.ts,我设计的实体结构如下:

import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity('dicts')
export class DictsEntity {
  @PrimaryGeneratedColumn()
  @Column({ comment: '主键', primary: true })
  id: number;

  @Column({ length: 50, comment: '字典组key值' })
  dict: string;

  @Column({ length: 50, comment: '字典组名称', nullable: true })
  dictName: string;

  @Column({ length: 50, comment: '字典名称', nullable: true })
  label: string;

  @Column({ length: 50, comment: '字典key值' })
  value: string;

  @CreateDateColumn({ type: 'timestamp', comment: '创建时间' })
  createTime: string;

  @UpdateDateColumn({ type: 'timestamp', comment: '更新时间' })
  updateTime: string;

  @Column({ length: 200, comment: '备注', nullable: true })
  remark: string;
}

@Entity('dicts') dicts表示对应数据库中的dicts表

@PrimaryGeneratedColumn() 表示这是一个自增列 如果是@PrimaryGeneratedColumn('uuid') 表示列数据是唯一的随机字符串

@Column({ length: 50, comment: '字典名称', nullable: true }) 如果不写type 默认是varchar; length表示长度最大为50; comment表示该列的注释信息; nullable为true时表示数据可以为空,默认是false,不能为空; primary表示该列是主键;

@CreateDateColumn() 表示数据在插入的时候会自动填充时间

@UpdateDateColumn() 表示该行数据每次有字段更新时,会自动更新时间

更多装饰器以及参数说明详见官网

3.连接数据库

在根路径下新建文件ormconfig.json(官网提供几种连接数据库的方式,我这里用的是单独文件配置),然后配置如下:

{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "admin",
  "database": "nest_demo",
  "entities": ["dist/**/*.entity{.ts,.js}"],
  "synchronize": true
}

根据你的数据库配置信息填写

entities 是一个数组,写你打包后存放的实体路径

synchronize 是否开启同步,开启后,会自动根据你编写的实体同步数据库表的设置。 不推荐生产环境使用

更多参数详见官网

然后在app.module.ts去引用TypeORM

......
import { TypeOrmModule } from '@nestjs/typeorm';
......

@Module({
  imports: [TypeOrmModule.forRoot()],
  ......
})
export class AppModule {}

启动项目你在数据库中得到dicts表,如下


*** 在这一步需要注意的问题**

  • ormconfig.json里的database名称需要小写,否则在使用synchronize: true时,修改了实体字段会报表已存在
  • 如果你启动遇到了一些问题,可以先把dist删除,然后重新启动看看问题是否还存在
  • 如果你遇到了wrong driver的错误,这可能是typeorm版本的问题,可以修改版本进行尝试。我这里用的是 @nestjs/typeorm@8.0.4 typeorm@0.3.6

4.操作数据库

TypeORM提供三种方式操作数据,connection,entity manager和repository。具体有什么区别,还需要深入了解一下。

然后我们找到之前的app.service.ts 和 app.controller.ts,简单的修改一下,得到下面代码

import { Injectable } from '@nestjs/common';
import { DictsEntity } from 'entity/dicts.entity';
import { getConnection } from 'typeorm';

@Injectable()
export class AppService {
  /**
   * 查询数据字典
   */
  async getDict(dict: string): Promise<DictsEntity[]> {
    const res = await getConnection().manager.getRepository(DictsEntity).find({
      where: {
        dict,
      },
    });

    return res;
  }
}
import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from './app.service';
import * as DTO from './app.dto';

@Controller('common')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('dict')
  async getDict(@Query() query: DTO.getDictReq): Promise<DTO.getDictRes[]> {
    const res = await this.appService.getDict(query.dictType);

    return res.map((i) => ({
      label: i.label,
      value: i.value,
    }));
  }
}

不出意外我们会得到如下结果:

到这里,这个字典接口算是被我们孵化出来了。撒花★,°:.☆( ̄▽ ̄)/$:.°★

7.生成Swagger文档

接口完成之后,我们不能孤芳自赏,我们需要把接口的入参和出参告诉给前端(指我自己)。

nest可以搭配swagger自动生成接口文档地址

1.安装依赖

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

如果nest底层用的是fastify,这里的swagger-ui-express就要改成fastify-swagger

2.在main.ts里定义并初始化

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

async function bootstrap() {
  ......
  // swagger
  const config = new DocumentBuilder()
    .setTitle('Nest-Demo')
    .setDescription('接口的诞生')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('backend', app, document);
  await app.listen(3000);
}
bootstrap();

然后在地址栏输入localhost:3000/backend就可以看到生成的接口文档了。但是目前仅展示接口的基本信息,我们下面需要对一些信息进行补充。

3.swagger 信息设置

我们这里就简单介绍四种信息设置,其它的详见官网。

  • 给接口划分标签,可以标记为属于哪个模块

我们在app.controller.ts找到AppController,在上面使用@ApiTags('公共模块') ,把当前controller下的接口统一划分到你填写的标签下。我这里因为就一个接口,所以没有按照模块划分去开发接口。

  • 接口说明

还是在app.controller.ts下找到getDict接口,在上面使用@ApiOperation({ summary: '查询字典' }),标记这个接口是实现了查询字典功能

  • 接口出参说明

还是在app.controller.ts下找到getDict接口,在上面使用@ApiResponse({ status: 200, description: '返回data如下', schema: ... }) schema的语法参见swagger

  • 接口入参说明

找到之前的app.dto.ts文件,在我们约定的入参getDictReq类中,找到dictType,使用@ApiProperty({ description: '字典组key值' })表示这个字段需要传字典组的key值

import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from './app.service';
import * as DTO from './app.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

@ApiTags('公共模块')
@Controller('common')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @ApiOperation({ summary: '查询字典' })
  @ApiResponse({
    status: 200,
    description: '返回的data如下',
    schema: {
      type: 'array',
      items: {
        type: 'object',
        properties: {
          label: {
            type: 'string',
            example: 'Nest',
          },
          value: {
            type: 'string',
            example: 'Nest',
          },
        },
      },
    },
  })
  @Get('dict')
  async getDict(@Query() query: DTO.getDictReq): Promise<DTO.getDictRes[]> {
    const res = await this.appService.getDict(query.dictType);

    return res.map((i) => ({
      label: i.label,
      value: i.value,
    }));
  }
}

修改后我们重新打开localhost:3000/backend,这样的接口文档就清晰很多了。

完结

终于写完了。下一期学习下如何实现登录功能,并通过jwt对接口权限进行管理。挖个坑🕳,溜~