本期精读的文章是:Nestjs 文档
体验一下 nodejs mvc 框架的优雅设计。
Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。
Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。
Nestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Providers 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Provider。
装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化:
@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, Providers 之间通过依赖注入相互关联,它们通过同名的 @Module
@Controller
@Injectable
装饰器申明,如:
@Controller() export class UsersController { @Get('users') getAllUsers() {} }
@Injectable() export class UsersService { getAllUsers() { return [] } }
@Module({ controllers: [ UsersController ], providers: [ UsersService ], }) export class ApplicationModule {}
在 ApplicationModule
申明其内部 Controllers 与 Providers 后,就可以在 Controllers 中注入 Providers 了:
@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 参数。
有了如此强大的后端框架,必须搭配上同等强大的 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 效率非常高。
本文作者:前端小毛
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!