精读 《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 的概念通过装饰器实现:
@Controller('users')
export class UsersController {
@Get()
getAllUsers(req: Request, res: Response, next: NextFunction) {}
}
访问 /users
时会进入 getAllUsers
函数。可以看到其 namespace
也是去中心化的。
2.3 模块间依赖注入
Modules, Controllers, Components 之间通过依赖注入相互关联,它们通过同名的 @Module
@Controller
@Component
装饰器申明,如:
@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 了:
@Controller()
export class UsersController {
constructor(private usersService: UsersService) {}
@Get('users')
getAllUsers() {
return this.usersService.getAllUsers()
}
}
2.4 装饰器参数
与大部分框架从 this.req
或 this.context
等取请求参数不同,Nestjs 通过装饰器获取请求参数:
@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
做任何形式的校验:
@Column({
comment: '配置 JSON',
length: 5000,
})
@Validator.IsString({ message: '必须为字符串' })
@Validator.Length(0, 5000, { message: '长度在 0~5000' })
content: string;
这里遇到一个问题:新增实体时,需要校验所有字段,但更新实体时,由于性能需要,我们一般不会一次查询所有字段,就需要指定更新时,不校验没有赋值的字段,我们通过 Typeorm 的 EventSubscriber
完成数据库操作前的代码校验,并控制新增时全字段校验,更新时只校验赋值的字段,删除时不做校验:
@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 语句,完全不需要做错误处理,当校验失败或者数据库操作失败时,会自动终止执行后续代码,并返回给客户端友好的提示:
@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
四种,比如用户表到评论表,是一对多的关系,可以这样设置实体:
@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
:
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 后,再执行后面的启动脚本。
CMD ./scripts/docker/wait-for.sh && npm run deploy
以下是 wait.sh
脚本内容:
#!/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