本期精读的文章是:Nestjs 文档
体验一下 nodejs mvc 框架的优雅设计。
Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。
Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。
Nestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Components 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Component。
装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化:
@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
也是去中心化的。
Modules, Controllers, Components 之间通过依赖注入相互关联,它们通过同名的 @Module
@Controller
@Component
装饰器申明,如:
@Controller()
export class UsersController {
@Get('users')
getAllUsers() {}
}
@Component()
export class UsersService {
getAllUsers() {
return []
}
}
@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()
}
}
与大部分框架从 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 参数。
由于临近双十一,项目工期很紧张,本期精读由我独自完成 :p。
有了如此强大的后端框架,必须搭配上同等强大的 orm 才能发挥最大功力,Typeorm 就是最好的选择之一。它也完全使用 Typescript 编写,使用方式具有同样的艺术气息。
每个实体对应数据库的一张表,Typeorm 在每次启动都会同步表结构到数据库,我们完全不用使用数据库查看表结构,所有结构信息都定义在代码中:
@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()
时已经带上了初始值。
光判断参数类型是不够的,我们可以使用 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);
}
外键也是 Typeorm 的特色之一,通过装饰器语义化解释实体之间的关系,常用的有 @OneToOne
@OneToMany
@ManyToOne@ManyToMany
四种,比如用户表到评论表,是一对多的关系,可以这样设置实体:
@Entity()
export class User {
@PrimaryGeneratedColumn({
comment: '主键',
})
id: number;
@OneToMany(type => Comment, comment => comment.user)
comments?: Comment[];
}
@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
中。
可以使用 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
Nestjs 中间件实现也很精妙,与 Modules 完美结合起来,由于篇幅限制就不展开了。
后端框架已经很成熟了,相反前端发展的就眼花缭乱了,如果前端可以舍弃 ie11 浏览器,我推荐纯 proxy 实现的 dob,配合 react 效率非常高。
讨论地址是:精读 《Nestjs 文档》 · Issue #30 · dt-fe/weekly