如何从零开始设计一个契合自己业务需求的 NestJS 架构

Node.js 后端框架那么多,但归根结底都太「简单」了。这种简单是指他们都是技术层面的抽象,并不很适合直接用于业务的开发,需要开发人员针对自己的业务场景进行一定的封装。

使用 NestJS 的时候,其实也是如此的,但是 NestJS 提供了强大的模块系统以及控制反转的机制,基于此,我们可以更轻松地打造出一个易于维护的后端业务框架。

这篇文章主要讲 HTTP 业务场景下的相关内容,如果有不正确或者有更优解的地方欢迎大佬们指正。

基本的 HTTP 请求

从一个最基本的 HTTP 请求讲起,因为这是一个业务系统中的核心接口能力。在 NestJS 里,这通过装饰器便能够实现:

1
2
3
4
5
6
7
8
9
import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
}

这个 Controller 在 NestJS 中如果能被正确受理,那它需要被加载到 NestJS App 实例上,在上述示例中便是通过在 AppModule 模块中注明:

1
2
3
4
5
6
7
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';

@Module({
controllers: [CatsController],
})
export class AppModule {}

最终通过 NestJS API 使用 AppModule 创建整个应用。过多的不再赘述,在完成一个完整的请求过程中可以做许多事情,这便要提到 NestJS 的生命周期了。

NestJS 的生命周期

详细的文档可以在 官网 看到,这里只是强调一下整个链路:

1
HTTP 请求 --> 中间件 --> 守卫 --> 拦截器 --> 管道 --> 过滤器 --> 控制器

说白了,上述的各个部分其实都是「中间件」,只不过在 NestJS 中赋予了各个环节更加清晰和语义化的表述,让各个类别的「中间件」各司其职,在代码上能更好的解耦各个部分。

剩下的便是参照文档学习并且对他们加以组合。我在业务中会在中间件里打印请求日志以及设置 Context ID,在守卫中处理 Token 的校验,拦截器进行返回值的转换,管道则是进行请求参数的转换和校验等。

不过我们的系统,是一个对接了多方接口的服务,它对接了多方厂家的接口标准,而不仅仅是我们自己的业务接口规范,所以这里有一些小技巧可供参考:

  • 通过「数据传输模型(DTO)」的装饰器进行标识,并在 Pipe 中判断不同类型的 DTO 使用不同的转换方式(微信的 XML 转换、银行的解密操作等),最终得到一致的数据模型(DTO 的实例对象)并传给控制器。
  • 通过控制器的注解标注响应的类型,最后在拦截器中进行上述方法的反向操作,将系统内的数据格式转换为业务对接方的接口规范。

异常捕获

异常捕获跟随官方文档可以比较容易地理解和使用,不过除了 NestJS 自带的 HTTPException 之外,我还封装了很多不同的业务错误类,其中包括自身业务场景的异常,直接抛出便能返回系统自身能够受理的响应格式:

1
2
3
4
5
6
@Injectable()
class CatService {
async miao() {
throw new CommonException(100, 'error message'); // 直接返回 HTTP Code 是 200 并且携带业务错误代码的异常
}
}

还有在跟银行对接的时候通过类似名如 BankException 的异常,生成合适的错误信息并且加密后返回给银行。

如此封装便可以很灵活的集合多种业务方,并且在编码时不用过于传递过多的返回值,异常的状态直接抛出交由框架进行处理。

目录结构

一个合理的项目除了设计业务中的相关工作外,合理的目录结构也是必须的。

在我开发与维护的项目中,主要的几个点在于:

  • 配置目录(config)
  • 框架基础与公共工具库目录(common)
  • 业务公用目录(shared)
  • 主体业务目录(app)

所以一个大概的目录结构看起来是这样:

1
2
3
4
5
6
7
8
9
project
├── src
│ ├── app/
│ ├── config/
│ ├── common/
│ ├── shared/
│ ├── app.module.ts
│ └── main.ts
└── package.json

在 app 目录中,还会根据不同的业务功能进行目录的划分,最后统一将模块挂载到 app.module.ts 中即可。整体的业务部分是一个树状结构,而会造成循环依赖的场景则尽可能抽离出公共业务放置到 shared 目录中,common 目录则是放置上述的拦截器云云。

配置

配置是一个项目启动的关键部分,它保管着各种服务的连接方式,而且不同的环境各有一份配置。

我在环境的切换上是通过环境变量实现的,也就是读取 NODE_ENV 这一环境变量。上文中提到了「配置目录」,这里可以存放具体的配置信息,也可以按自己的需求定制。我们项目是在 Nacos 中读取配置文件并进行加载,加载完毕后对整个项目初始化。其中配置原先只是写了一个模板,实际项目加载后会通过模板中的字符串替换为正确的值。

如果没有 Remote Config 这种需求的话,用官方的方式也是不错的。

基本服务的使用与接入

一个项目自己跑起来是没什么意思的,毕竟不是一个纯计算用的服务。与之配套的则是数据库缓存、消息队列、外部 HTTP 资源等组件。

数据库

数据库虽然 Typeorm 与 NestJS 结合的不错了,但是到处在 Module 类装饰器中多写点东西总归是不那么得劲,这些公共的部分还是抽离为全局模块更舒服一些,所以朋友之前工作时封装了 这个库 进行数据库的操作,不建议直接使用,但是可以查看代码了解一下这个模块是如何封装的。总之最后在项目中引入数据库,通过一个 @InjectService 注解即可:

1
2
3
4
5
6
export class OrmService {
constructor(
@InjectService(TestEntity)
private testService: Service<TestEntity>,
) {}
}

这个库还封装了一些常见的数据库操作以及加了一些字段类型用于软删除、强制填写创建人等功能,挺强大的。

缓存

缓存的用法可就多了,这里列几个比较常用的方法:

  • 也是通过封装为全局模块,在服务中引入并且直接读写指定 key;
  • 使用 CacheProvider 提供的装饰器来缓存程序中指定的方法的返回值;
  • Redlock
  • 想到再补充

消息队列

项目中用到的消息队列主要有两种:

  • 基于 Redis 的 Bull 消息队列
  • RabbitMQ

二者在易用性和可靠性上都各有优劣,互相作为互补使用。而且实例也是尽量通过装饰器给封装成全局可用,方便在代码中的调用,简化一个模块的依赖。

HTTP 请求

说实话这个东西还是蛮重要的,很多第三方服务,项目关联的微服务间调用都需要 HTTP 请求。之前是基于 NestJS 自带的 HTTPService 封装了一个全局模块,自定义了日志拦截器、NameServer 等。

前段时间我搞了一个 fishing 库,戳这里去看,准备慢慢完善一下并在项目中运用起来。

日志

几乎每个地方都逃不开「日志」,它是一个系统追踪问题最直观的工具。

同样的,简化模块引用的目的下,日志也是通过装饰器引入:

1
2
3
4
5
6
7
@Injectable()
export class TestService {
constructor(
@InjectLogger(TestService)
private logger: LoggerProvider,
) {}
}

这样日志在打印时会自动打印上下文。

在我的 NestJS 框架中,一个请求从进入便被打上了标记,这个标记则会伴随它一直到响应结束。

在中间件中,HTTP 请求进入后,会打印请求的 Request 日志,但是在此之前,会先生成(或者从请求头中获取)一个 ID,在这里称之为 Context ID,它的作用是在整个函数调用栈中都可以拿到这个 ID,一般用于在打印日志是将之打印,最后在日志中可以追踪链路。这个功能的实现使用的叫 cls-hooked 的库,它是基于 async_hooks 这一官方库封装的,感兴趣的同学可以去了解一下。

小结

自此,一个项目架构基本上便可以实现了,还有一些细节的地方,主要是工作量了。