# 精读 《Nestjs 文档》

本期精读的文章是：[Nestjs 文档](https://docs.nestjs.com/)

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

# 1 引言

<img src="https://img.alicdn.com/imgextra/i3/O1CN01t69Fct1E39Voy7ohY_!!6000000000295-2-tps-971-1200.png" width="500" alt="logo" />

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

# 2 内容概要

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

## 2.1 Modules, Controllers, Providers

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

## 2.2 装饰器路由

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

```typescript
@Controller()
export class UsersController {
    @Get('users')
    getAllUsers() {}

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

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

以前用过 Go 语言框架 [Beego](https://beego.me/docs/mvc/controller/router.md)，就是采用了中心化路由管理方式，虽然引入了 `namespace` 概念，但当协作者多、模块体量巨大时，路由管理成本直线上升。Nestjs 类似 namespace 的概念通过装饰器实现：

```typescript
@Controller('users')
export class UsersController {
    @Get()
    getAllUsers(req: Request, res: Response, next: NextFunction) {}
}
```

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

## 2.3 模块间依赖注入

Modules, Controllers, Providers 之间通过依赖注入相互关联，它们通过同名的 `@Module` `@Controller` `@Injectable` 装饰器申明，如：

```typescript
@Controller()
export class UsersController {
    @Get('users')
    getAllUsers() {}
}
```

```typescript
@Injectable()
export class UsersService {
    getAllUsers() {
        return []
    }
}
```

```typescript
@Module({
    controllers: [ UsersController ],
    providers: [ UsersService ],
})
export class ApplicationModule {}
```

在 `ApplicationModule` 申明其内部 Controllers 与 Providers 后，就可以在 Controllers 中注入 Providers 了：

```typescript
@Controller()
export class UsersController {
	constructor(private usersService: UsersService) {}
	
    @Get('users')
    getAllUsers() {
    	return this.usersService.getAllUsers()
    }
}
```

## 2.4 装饰器参数

与大部分框架从 `this.req` 或 `this.context` 等取请求参数不同，Nestjs 通过装饰器获取请求参数：

```typescript
@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](https://github.com/typeorm/typeorm) 就是最好的选择之一。它也完全使用 Typescript 编写，使用方式具有同样的艺术气息。

### 3.1.1 定义实体

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

```typescript
@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` 做任何形式的校验：

```typescript
@Column({
	comment: '配置 JSON',
	length: 5000,
})
@Validator.IsString({ message: '必须为字符串' })
@Validator.Length(0, 5000, { message: '长度在 0~5000' })
content: string;
```

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

```typescript
@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 语句，完全不需要做错误处理，当校验失败或者数据库操作失败时，会自动终止执行后续代码，并返回给客户端友好的提示：

```typescript
@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` 四种，比如用户表到评论表，是一对多的关系，可以这样设置实体：

```typescript
@Entity()
export class User {
  @PrimaryGeneratedColumn({
    comment: '主键',
  })
  id: number;

  @OneToMany(type => Comment, comment => comment.user)
  comments?: Comment[];
}
```

```typescript
@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`：

```yml
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 后，再执行后面的启动脚本。

```bash
CMD ./scripts/docker/wait-for.sh && npm run deploy
```

以下是 `wait.sh` 脚本内容：

```bash
#!/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](https://github.com/ascoders/dob)，配合 react 效率非常高。

> 讨论地址是：[精读 《Nestjs 文档》 · Issue #30 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/30)

> 如果你想参与讨论，请[点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，每周五发布。
