20. 精读《Nestjs》

2022-03-14 15:50:46 浏览数 (1)

精读 《Nestjs 文档》

本期精读的文章是:Nestjs 文档

体验一下 nodejs mvc 框架的优雅设计。

1 引言

Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。

2 内容概要

Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。

2.1 Modules, Controllers, Components

Nestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Components 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Component。

2.2 装饰器路由

装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化:

代码语言:javascript复制
@Controller()
export class UsersController {
    @Get('users')
    getAllUsers() {}

    @Get('users/:id')
    getUser() {}

    @Post('users')
    addUser() {}
}

以前用过 Go 语言框架 Beego,就是采用了中心化路由管理方式,虽然引入了 namespace 概念,但当协作者多、模块体量巨大时,路由管理成本直线上升。Nestjs 类似 namespace 的概念通过装饰器实现:

代码语言:javascript复制
@Controller('users')
export class UsersController {
    @Get()
    getAllUsers(req: Request, res: Response, next: NextFunction) {}
}

访问 /users 时会进入 getAllUsers 函数。可以看到其 namespace 也是去中心化的。

2.3 模块间依赖注入

Modules, Controllers, Components 之间通过依赖注入相互关联,它们通过同名的 @Module @Controller @Component 装饰器申明,如:

代码语言:javascript复制
@Controller()
export class UsersController {
    @Get('users')
    getAllUsers() {}
}
代码语言:javascript复制
@Component()
export class UsersService {
    getAllUsers() {
        return []
    }
}
代码语言:javascript复制
@Module({
    controllers: [ UsersController ],
    components: [ UsersService ],
})
export class ApplicationModule {}

ApplicationModule 申明其内部 Controllers 与 Components 后,就可以在 Controllers 中注入 Components 了:

代码语言:javascript复制
@Controller()
export class UsersController {
	constructor(private usersService: UsersService) {}
	
    @Get('users')
    getAllUsers() {
    	return this.usersService.getAllUsers()
    }
}

2.4 装饰器参数

与大部分框架从 this.reqthis.context 等取请求参数不同,Nestjs 通过装饰器获取请求参数:

代码语言:javascript复制
@Get('/:id')
public async getUser(
	@Response() res,
	@Param('id') id,
) {
    const user = await this.usersService.getUser(id);
    res.status(HttpStatus.OK).json(user);
}

@Response 获取 res,@Param 获取路由参数,@Query 获取 url query 参数,@Body 获取 Http body 参数。

3 精读

由于临近双十一,项目工期很紧张,本期精读由我独自完成 :p。

3.1 Typeorm

有了如此强大的后端框架,必须搭配上同等强大的 orm 才能发挥最大功力,Typeorm 就是最好的选择之一。它也完全使用 Typescript 编写,使用方式具有同样的艺术气息。

3.1.1 定义实体

每个实体对应数据库的一张表,Typeorm 在每次启动都会同步表结构到数据库,我们完全不用使用数据库查看表结构,所有结构信息都定义在代码中:

代码语言:javascript复制
@Entity()
export class Card {
  @PrimaryGeneratedColumn({
    comment: '主键',
  })
  id: number;

  @Column({
    comment: '名称',
    length: 30,
    unique: true,
  })
  name: string = 'nick';
}

通过 @Entity 将类定义为实体,每个成员变量对应表中的每一列,如上定义了 id name 两个列,同时列 id 通过 @PrimaryGeneratedColumn 定义为了主键列,列 name 通过参数定义了其最大长度、唯一的信息。

至于类型,Typeorm 通过反射,拿到了类型定义,自动识别 id 为数字类型、name 为字符串类型,当然也可以手动设置 type 参数。

对于初始值,使用 js 语法就好,比如将 name 初始值设置为 nick,在 new Card() 时已经带上了初始值。

3.1.2 自动校验

光判断参数类型是不够的,我们可以使用 class-validator 做任何形式的校验:

代码语言:javascript复制
@Column({
	comment: '配置 JSON',
	length: 5000,
})
@Validator.IsString({ message: '必须为字符串' })
@Validator.Length(0, 5000, { message: '长度在 0~5000' })
content: string;

这里遇到一个问题:新增实体时,需要校验所有字段,但更新实体时,由于性能需要,我们一般不会一次查询所有字段,就需要指定更新时,不校验没有赋值的字段,我们通过 Typeorm 的 EventSubscriber 完成数据库操作前的代码校验,并控制新增时全字段校验,更新时只校验赋值的字段,删除时不做校验:

代码语言:javascript复制
@EventSubscriber()
export class EverythingSubscriber implements EntitySubscriberInterface<any> {
  // 插入前校验
  async beforeInsert(event: InsertEvent<any>) {
    const validateErrors = await validate(event.entity);
    if (validateErrors.length > 0) {
      throw new HttpException(getErrorMessage(validateErrors), 404);
    }
  }

  // 更新前校验
  async beforeUpdate(event: UpdateEvent<any>) {
    const validateErrors = await validate(event.entity, {
      // 更新操作不会验证没有涉及的字段
      skipMissingProperties: true,
    });
    if (validateErrors.length > 0) {
      throw new HttpException(getErrorMessage(validateErrors), 404);
    }
  }
}

HttpException 会在校验失败后,终止执行,并立即返回错误给客户端,这一步体现了 Nestjs 与 Typeorm 完美结合。这带来的好处就是,我们放心执行任何 CRUD 语句,完全不需要做错误处理,当校验失败或者数据库操作失败时,会自动终止执行后续代码,并返回给客户端友好的提示:

代码语言:javascript复制
@Post()
async add(
  @Res() res: Response,
  @Body('name') name: string,
  @Body('description') description: string,
) {
  const card = await this.cardService.add(name, description);
  // 如果传入参数实体校验失败,会立刻返回失败,并提示 `@Validator.IsString({ message: '必须为字符串' })` 注册时的提示信息
  // 如果插入失败,也会立刻返回失败
  // 所以只需要处理正确情况
  res.status(HttpStatus.OK).json(card);
}

3.1.3 外键

外键也是 Typeorm 的特色之一,通过装饰器语义化解释实体之间的关系,常用的有 @OneToOne @OneToMany @ManyToOne@ManyToMany 四种,比如用户表到评论表,是一对多的关系,可以这样设置实体:

代码语言:javascript复制
@Entity()
export class User {
  @PrimaryGeneratedColumn({
    comment: '主键',
  })
  id: number;

  @OneToMany(type => Comment, comment => comment.user)
  comments?: Comment[];
}
代码语言:javascript复制
@Entity()
export class Comment {
  @PrimaryGeneratedColumn({
    comment: '主键',
  })
  id: number;

  @ManyToOne(type => User, user => user.Comments)
  @JoinColumn()
  user: User;
}

User 来说,一个 User 对应多个 Comment,就使用 OneToMany 装饰器装饰 Comments 字段;对 Comment 来说,多个 Comment 对应一个 User,所以使用 ManyToOne 装饰 User 字段。

在使用 Typeorm 查询 User 时,会自动外键查询到其关联的评论,保存在 user.comments 中。查询 Comment 时,会自动查询到其关联的 User,保存在 comment.user 中。

3.2 部署

可以使用 Docker 部署 Mysql Nodejs,通过 docker-compose 将数据库与服务都跑在 docker 中,内部通信。

有一个问题,就是 nodejs 服务运行时,要等待数据库服务启动完毕,也就是有一个启动等待的需求。可以通过 environment来拓展等待功能,以下是 docker-compose.yml

代码语言:javascript复制
version: "2"
services:
  app:
    build: ./
    restart: always
    ports:
      - "5000:8000"
    links:
      - db
      - redis
    depends_on:
      - db
      - redis
    environment:
      WAIT_HOSTS: db:3306 redis:6379

通过 WAIT_HOSTS 指定要等待哪些服务的端口服务 ready。在 nodejs Dockerfile 启动的 CMD 加上一个 wait-for.sh 脚本,它会读取 WAIT_HOSTS 环境变量,等待端口 ready 后,再执行后面的启动脚本。

代码语言:javascript复制
CMD ./scripts/docker/wait-for.sh && npm run deploy

以下是 wait.sh 脚本内容:

代码语言:javascript复制
#!/bin/bash

set -e

timeout=${WAIT_HOSTS_TIMEOUT:-30}
waitAfterHosts=${WAIT_AFTER_HOSTS:-0}
waitBeforeHosts=${WAIT_BEFORE_HOSTS:-0}

echo "Waiting for ${waitBeforeHosts} seconds."
sleep $waitBeforeHosts

# our target format is a comma separated list where each item is "host:ip"
if [ -n "$WAIT_HOSTS" ]; then
  uris=$(echo $WAIT_HOSTS | sed -e 's/,/ /g' -e 's/s /n/g' | uniq)
fi

# wait for each target
if [ -z "$uris" ];
  then echo "No wait targets found." >&2;

  else

  for uri in $uris
  do
    host=$(echo $uri | cut -d: -f1)
    port=$(echo $uri | cut -d: -f2)
    [ -n "${host}" ]
    [ -n "${port}" ]
    echo "Waiting for ${uri}."
    seconds=0
    while [ "$seconds" -lt "$timeout" ] && ! nc -z -w1 $host $port
    do
      echo -n .
      seconds=$((seconds 1))
      sleep 1
    done

    if [ "$seconds" -lt "$timeout" ]; then
      echo "${uri} is up!"
    else
      echo "  ERROR: unable to connect to ${uri}" >&2
      exit 1
    fi
  done
echo "All hosts are up"
fi

echo "Waiting for ${waitAfterHosts} seconds."
sleep $waitAfterHosts

exit 0

4 总结

Nestjs 中间件实现也很精妙,与 Modules 完美结合起来,由于篇幅限制就不展开了。

后端框架已经很成熟了,相反前端发展的就眼花缭乱了,如果前端可以舍弃 ie11 浏览器,我推荐纯 proxy 实现的 dob,配合 react 效率非常高。

讨论地址是:精读 《Nestjs 文档》 · Issue #30 · dt-fe/weekly

0 人点赞