目录

NestJS + TypeORM 实现 crud 示例

随着代码量的增长传统服务端 MVC 模式中 Modal 和 Controller 会变得含糊不清,导致难于维护。下面是传统 MVC 服务端架构:

/img/mvc.jpg
传统MVC架构

Nest(Nest.js) 的分层借鉴自 Spring,更细化。我们应该要了解整个 Nest 框架的三层结构,Nest 和传统的 MVC 框架的区别在于它更注重于后端部分(控制器、服务与数据)的架构,视图层相对比较独立,完全可以由用户自定义配置。

/img/nest.jpg
NestJS三层架构

创建 Nest 项目

$ npm install -g @nest/cli 全局安装 nest 脚手架

$ nest new nest-crud 新建 nest.js 项目, 选择 yarn 作为开发工具

$ nest g mo photo 建立 PhotoModule

$ nest g co photo 建立 PhotoController

$ nest g s photo 建立 PhotoService

$ yarn add @nestjs/typeorm typeorm mysql 需要使用 typeorm, mysql 需要安装这些库

在 TypeORM 中数据库的表对应的就是一个类,通过定义一个类来创建实体。实体(Entity)是一个映射到数据库表的类 (类似于 mongoose 中的 Schema 映射到 MongoDB 的 collection),通过@Entity()来标记。在 photo 文件夹中新建 photo.entity.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// photo.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity("photo")
export class PhotoEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 500 })
  name: string;

  @Column("text")
  description: string;

  @Column()
  filename: string;

  @Column("int")
  views: number;

  @Column()
  isPublished: boolean;
}

在 app.module.ts 中的 import 数组中配置数据库连接,可以配置多个数据库连接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TypeOrmModule.forRoot({
  type: "mysql",
  host: "localhost",
  port: 3306,
  username: "root",
  password: "123456",
  database: "test",
  entities: [PhotoEntity],
  synchronize: true,
});

然后在 photo.mudule.ts 中 import 数组中注册要本模块使用的数据库。这样,我们就可以使用 @InjectRepository() 装饰器将 PhotoRepository 注入到 PhotoService 中

1
imports: [TypeOrmModule.forFeature([PhotoEntity])];

数据传输对象简称 DTO(Data Transfer Object),是一组需要跨进程或网络边界传输的聚合数据的简单容器。它不应该包含业务逻辑,并将其行为限制为诸如内部一致性检查和基本验证之类的活动。class-validator 可以很方便地验证前端传过来的参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// photo.dto.ts

import { IsString, IsInt, IsBoolean } from "class-validator";

export class PhotoDto {
  @IsInt()
  readonly id: number;

  @IsString()
  readonly name: string;

  @IsString()
  readonly description: string;

  @IsString()
  readonly filename: string;

  @IsInt()
  readonly views: number;

  @IsBoolean()
  readonly isPublished: boolean;
}

三层结构

将 PhotoRepository 注入到 PhotoService 中, 写数据库操作的 crud 代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Injectable()
export class PhotoService {
  constructor(
    @InjectRepository(PhotoEntity)
    private readonly photoRepository: Repository<PhotoEntity>
  ) {}

  async findAll(): Promise<PhotoEntity[]> {
    return this.photoRepository.find();
  }

  async create(photoDto: PhotoDto): Promise<PhotoEntity> {
    return await this.photoRepository.save(photoDto);
  }

  async delete(id: number) {
    return await this.photoRepository.delete(id);
  }

  async update(photoDto: PhotoDto) {
    return await this.photoRepository.update(photoDto.id, photoDto);
  }

  async findOne(id: number): Promise<PhotoEntity> {
    return await this.photoRepository.findOne(id);
  }
}

将 PhotoService 注入到 PhotoController 中, 写 api 路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Controller("photo")
export class PhotoController {
  constructor(private readonly photoService: PhotoService) {}

  @Get()
  findAll(): Promise<PhotoEntity[]> {
    return this.photoService.findAll();
  }

  @Post("create")
  create(@Body() PhotoDto: PhotoDto): Promise<PhotoEntity> {
    return this.photoService.create(PhotoDto);
  }

  @Delete("delete/:id")
  delete(@Param("id") id: number) {
    return this.photoService.delete(id);
  }

  @Put("update/:id")
  update(@Param("id") id: number, @Body() PhotoDto: PhotoDto) {
    return this.photoService.update(PhotoDto);
  }

  @Get(":id")
  findOne(@Param("id") id: number): Promise<PhotoEntity> {
    return this.photoService.findOne(id);
  }
}

接下来在 main.ts 中配置 swagger, 方便我们测试 api

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const app = await NestFactory.create(AppModule);

const options = new DocumentBuilder()
  .setTitle("photo example")
  .setDescription("The photo API description")
  .setVersion("0.0.1")
  .build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup("docs", app, document);

await app.listen(3000);

$ npm run start 启动 nest 项目, 访问 http://localhost:3000/docs ,到这里基本的 crud 操作已经实现,此时,NestJS 框架的三层结构已有体现。接下来再完善项目。

AOP 的思想

我们在 PhotoController 的路由请求参数中传入了 DTO, 做了直接的参数校验。传入类型不符合要求时,会直接报错。DTO 中的 class-validator 还需要配合 pipe 才能完成校验功能。新建一个 pipe 捕获异常。$ nest g pi section/validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value, metadata: ArgumentMetadata) {
    const { metatype } = metadata;
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      const errorMessage = _.values(errors[0].constraints)[0];
      throw new BadRequestException(errorMessage);
    }
    return value;
  }

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

有了这一层 pipe 帮助我们校验参数,有效地降低了类的复杂度,提高了可读性和可维护性。我们还可以对正确的请求,异常的请求进行包装,假设返回的格式是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 请求失败
{
    status: 1,
    message: string,
}

# 请求成功
{
    status: 0,
    message: '请求成功',
    data: any
}

可以利用 AOP 的思想去做这件事。全局捕获错误的切片层去处理所有的 exception,如果是一个成功的请求,需要把这个返回结果通过一个切片层包装一下。 在 NestJs 中,Exception Filter 是最后捕获 exception 的机会。我们把它作为处理全局错误的切片层。$ nest g f section/errors

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Catch()
export class ExceptionsFilter implements ExceptionFilter {
  async catch(exception, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    let message = exception.message;
    let isDeepestMessage = false;
    while (!isDeepestMessage) {
      isDeepestMessage = !message.message;
      message = isDeepestMessage ? message : message.message;
    }

    const errorResponse = {
      message: message || "请求失败",
      status: 1,
    };

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status);
    response.header("Content-Type", "application/json; charset=utf-8");
    response.send(errorResponse);
  }
}

而 Interceptor 则负责对成功请求结果进行包装:$ new g in section/transform

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, Response<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler
  ): Observable<Response<T>> {
    return next.handle().pipe(
      map((rawData) => {
        return {
          data: rawData,
          status: 0,
          message: "请求成功",
        };
      })
    );
  }
}

将 Interceptor, Exception Filter 和 Pipe 定义在全局范围内:

1
2
3
app.useGlobalFilters(new ExceptionsFilter());
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalPipes(new ValidationPipe());

$ npm run start 打开 http://localhost:3000/docs, 测试 api 结果正如我们预期的那样。

附:源码地址

参阅资料