一直使用 php,go 进行项目开发,由于项目需要决定使用 js 作为项目的全栈语言。前端选用了 vue 框架。由于后台不仅需要提供 API,同时也需要提供页面渲染服务。所以在框架选择时,希望选择一款类似 php Laravel 的“瑞士军刀”型框架。经过衡量后,最终选择了 NestJS 框架。
后端开发常见功能(模块)需求
后端的应用可大可小,复杂程度也根据需求不断地变化。
以开发一个经典的 API 应用为例。
一个 API 应用的进程大概是这样的。前端发起 API 请求后,服务端响应后返回对应的数据(如 JSON 数据)。在这个过程中,可能会涉及到各种常见问题,如应用需要连接到数据库获取数据。这时候需要一个数据库连接管理或者直接需要一个 ORM 来统一与数据库的操作。基于性能考虑,还需要增加缓存。长时间的任务可能需要队列服务。在安全方面,对用户进行验证这也是必须的。
所以后台通常会包含各种组件(模块),除了基本的 Request 和 Response 的封装,还有 Cookie/Session、日志、 路由、ORM、缓存、认证或授权、队列服务、模板渲染、邮件发送等等功能丰富的内容。
所以开发应用时,选择一个功能(组件)完善的框架能够给开发者节省大量重造轮子的时间。
NestJS 为开发者省时
在 Node.js 领域里,express 框架一直占据着霸主地位。框架框架实现了服务器监听,封装了 Request 和 Response,还有 Session, Middleware 等其他常用组件。可谓时开发利器。
但随着应用的功能越来越多,项目变得越来越复杂。比如项目需要使用不同的认证方式,如账号密码登陆、Gmail 等第三方登陆。需要使用多个不同的数据库,如 Mysql 和 Redis。express 需要集成各种不同的组件,替换和使用的成本就慢慢升高。
同时还要处理各开发人员的协调问题。随着人员增多,需要对项目进行分工,不同的人负责不同的模块。合理的应用 express 也能够进行模块化的分工,但是需要团队内部制定一定的规则来约束。一旦某个成员没有遵循约定,可能导致整个项目的依赖缺失而崩溃。
另外,可能需要把各个不同项目之间公共代码抽取出来,并且可以快速应用到新项目中。模块化在这种场景显得更为重要。
从快速实现功能以满足市场需求来看,重复造轮子是比较费时的一种方式。因此,如果有一种框架能够达到目的,那未尝不是一个较优选择。
NestJS 就是这样一个框架,瑞士军刀一样的框架。它提供即用的应用构架:高度可测试性,可扩展性,松耦合,易于维护。
NestJS 一个 Node.js 的服务端应用框架,完全支持 TypeScript,并且支持 OOP(面向对象)、FP(函数式)、FRP(函数响应式)编程模式。
NestJS 底层使用的是 Express 框架,当然也可以切换为 Fastify 框架。NestJS 给底层做了抽象封装,但也提供接口直接访问底层的 Express/Fastify 框架。
NestJS 核心框架介绍
在介绍 NestJS 常用框架之前,先对核心框架进行简单的介绍。NestJS 有几个非常重要的核心概念,分别是 Provider、Module、Controller、Middleware、Exception Filter、Pipe、Guaid、Interceptor 和装饰器。
安装 NestJS 非常简单,详细安装过程可参考官网。说明一下安装完成之后的文件结构。这代表常用的 NestJS 项目文件结构。
项目的项目目录结构如下。具体文件的内容可见注释描述。
src
- app.controller.spec.ts # Controller 测试文件
- app.controller.ts # Controller 路由处理器
- app.module.ts # Module 主模块入口 ,其他模块都需要在些进行注册
- app.service.ts # Service 业务逻辑或数据逻辑,根据实际需求使用
- main.ts # 入口文件
入口文件
其中入口文件如下所示:
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
入口文件中,创建主模块并监听端口号接收客户端请求。
Provider
Provider 是 NestJS 的基础。依赖注入是 Provider 的灵魂,也是 NestJS 的灵魂。Provider 的主要思路是它可以作为依赖被注入到实例中,在运行时才实例化该对象。
什么是依赖注入(dependency injection,简写为 DI)?依赖是指依靠某种东西来获得支持。将创建对象的任务转移给其他 class,并直接使用依赖项的过程就被“依赖注入”。
还有个比较重要的概念称为控制反转(Inversion of Control, 简写为 IoC),指一个类不应静态配置其依赖项,应由其他一些类从外部配置。
因为抽象不应该依赖实现,实现也不应该依赖实现,实现应该依赖抽象。这就是依赖倒置原则(DIP)。
运行时决定类的具体实例。比如说在自动化测试里面,如果不想连接数据库进行自动化测试,那么就可以传递另外的实例给应用。这个另外的实例只需要实现数据库调用相关的接口即可。
Module
应用都是由模块组成的,模块也可以依赖其他模块。数据库、缓存、配置、日志等都可以独立成一个模块。这些模块可以在不同的项目中应用。
当然也可以按照业务类型,将应用分成用户模块、订单模块、聊天模块。订单模块也可以分为多个子功能模块。还可以创建公共模块。只需要在公共模块中将对应内容重新 export 之后,只需要引用该模块即可使用该模块内容了。
Controller
控制器的作用是接收 http 请求并且返回数据响应。
路由直接绑定到控制器及控制器的方法上。在控制器的类上使用 @Controller()
装饰符可设置路由前缀,如 @Controller('user')
。
Middleware
Middleware 是一个函数,在路由处理器前执行。
Middleware 接收 request,response 和 next 作为参数。next 把控制权交给下一个 Middleware 或路由处理器。
NestJS 的 Middleware 与 express 的 Middleware 一致。
NestJS Middleware 的应用比较特殊,每个模块需要继承 NestModule 后重写 configure 方法。在 configure 里面使用 consumer 进行 apply.
import { Module, NestModule, MiddlewareConsumer } from "@nestjs/common";
import { LoggerMiddleware } from "./common/middleware/logger.middleware";
import { CatsModule } from "./cats/cats.module";
@Module({
imports: [CatsModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("cats");
}
}
全局应用 Middleware 可以像 express 一样,直接在 application 中使用 use 方法。
// main.ts
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);
Exception filter
NestJS 提供了处理错误层,让开发者可以处理那些未被捕获的错误。即应用没有处理相应的错误而直接被抛出时,则由此层接管。
默认处理 HttpException,如果不是这一类错误的话,直接向客户端返回 500 错误。具体如下:
{
"statusCode": 500,
"message": "Internal server error"
}
Pipe
Pipe 作用是对前端数据进行:
- 数据转换。比如将字符串转换为数字,常见于 id 处理。
- 数据验证。验证前端发送的数据是否符合 route handler 要求的数据。
Guide
守卫只有一个功能,即判断请求是否可以被路由处理器接收。
Guard 类似于 Middleware。但与 Middleware 不同的是,Guard 可以知道 next 函数执行的具体内容。
Guard 继承 CanActivate 接口,必须要实现 canActivate 函数。canActivate 函数返回一个布尔值。如果返回为 true,则可以控制交给路由处理器处理。如果返回 false,则抛出一个 ForbiddenException。
如需要其他返回,可自行在 canActivate 中抛出对应的错误。抛出的错误会被异常层接收并处理。
Guard 在所有 Middleware 之后执行,但在所有的 Interceptor 前执行。
Interceptor
拦截器是切面编程的技术。主要作用是:
- 在执行前、后绑定额外的业务逻辑
- 改变返回的数据
- 改变异常
- 扩展基础处理
- 根据某些条件,完全改变函数的执行。如缓存处理
拦截器实现NestInterceptor
接口。intercept
函数里,可调用 next 函数执行路由处理器。执行返回一个 Observer 。通过 rxjs 的 pipe 和 tab 或 map 进行操作。
自定义装饰器
装饰器在 Java 中是最常见的,但在 JS 中基本上没有。NestJS 大量使用装饰器。NestJS 在语言层面上内置了装饰器功能。装饰器的最大好处就是能够快速绑定到所需位置,而不需要额外的代码。提高效率的神器。
常用的装饰器:
- @Req @Request()
- @Res() @Response()
- @Query()
- @Param()
- @Body()
常用组件(模块)介绍
NestJS 之所以称之为“瑞士军刀”级别的框架,就是因为封装了许多开箱即用的组件。比如常用的 ORM 支持,缓存模块、配置模块、日志模块、队列任务等。
Database 数据库
NestJS 支持目前所有的数据库,包括常见的 SQL 或 NoSQL 数据库。NestJS 提供开箱即用的 typeorm,sequelize 和 mongoose 支持。
NestJS 封装了对应的 typeorm。可以使用所有 typeorm 的功能。但是 typeorm 如果发生异常时,整个应用容易崩溃。
配置
可以使用 .env 文件,也可以使用 ormconfig.js 文件。
Entity 实体
Entity 中可定义数据库结构,相当于是数据库列的对象化映射。
Repository 模式
通过 Repository 模式可实现 Entity 的 CURD。
Relation 实体关系
支持一对一,一对多,多对多的实体关系。
事务
- 通过 QueryRunner 执行
async createMany(users: User[]) {
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.save(users[0]);
await queryRunner.manager.save(users[1]);
await queryRunner.commitTransaction();
} catch (err) {
// since we have errors lets rollback the changes we made
await queryRunner.rollbackTransaction();
} finally {
// you need to release a queryRunner which was manually instantiated
await queryRunner.release();
}
}
- 通过 transation 执行
async createMany(users: User[]) {
await this.connection.transaction(async manager => {
await manager.save(users[0]);
await manager.save(users[1]);
});
}
事件订阅
TypeORM 支持事件订阅,通过事件订阅可以方便的监听 Entity 的事件。
主要事件有:
- AfterLoad
- BeforeInsert
- AfterInsert
- BeforeUpdate
- AfterUpdate
- BeforeRemove
- AfterRemove
可以直接在 Entity 中添加装饰器或使用 Subscriber 进行事件订阅。
装饰器样例:
@Entity()
export class Post {
@AfterRemove()
updateStatus() {
this.status = "removed";
}
}
事件订阅代码样例:
import {
Connection,
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from "typeorm";
import { User } from "./user.entity";
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
constructor(connection: Connection) {
connection.subscribers.push(this);
}
listenTo() {
return User;
}
beforeInsert(event: InsertEvent<User>) {
console.log(`BEFORE USER INSERTED: `, event.entity);
}
}
Cofiguration 配置
NestJS 提供开箱即用的 ConfigModule 模块。可以读取本地(.evn 文件)或远程配置。
支持 .env 文件,也可支持 yaml 等自定义格式,非常灵活。
默认读取项目根目录的 .env 文件。使用 get 方法可快速获取配置文件中对应 key 的值。
Log 日志
Log 功能开箱即用,并可自由扩展:
- 全局禁用
- 指定日志级别
- 重写时间格式
- 完全扩展 logger
- 方便测试
Validation
NestJS 中的 Validation 是通过 Pipe 管道实现的。
自动验证:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
自动验证返回 400 错误,类似:
{
"statusCode": 400,
"error": "Bad Request",
"message": ["email must be an email"]
}
如果 ValidationPipe 的 whitelist 设置为 true,则数据结构中多余的属性会自动被去除。如 User 的数据结构中只包含 email 和 password,但前端多传递了一个 age 的话,这个 age 属性将会被自动去除。
Cache 缓存
缓存是提高性能的最好方式。
NestJS 内置缓存模块,开箱即用的内存缓存,只需要简单设置即可使用。
import { CacheModule, Module } from "@nestjs/common";
import { AppController } from "./app.controller";
@Module({
imports: [CacheModule.register()],
controllers: [AppController],
})
export class AppModule {}
使用
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
const value = await this.cacheManager.get('key');
await this.cacheManager.set('key', 'value', { ttl: 1000 });
await this.cacheManager.del('key');
await this.cacheManager.reset(); // Clear all cache
使用 CacheInterceptor
自动缓存 HTTP 返回。
使用 Redis
import type { ClientOpts as RedisClientOpts } from "redis";
import * as redisStore from "cache-manager-redis-store";
import { CacheModule, Module } from "@nestjs/common";
import { AppController } from "./app.controller";
@Module({
imports: [
CacheModule.register <
RedisClientOpts >
{
store: redisStore,
// Store-specific configuration:
host: "localhost",
port: 6379,
},
],
controllers: [AppController],
})
export class AppModule {}
Serialization 序列化
Serialization 是在对象返回客户端之前的处理。通过这个步骤,可以对返回给客户端的数据进行过滤或添加不同的数据。比如去除敏感数据,如密码。
NestJS 使用 ClassSerializerInterceptor 控制全局的 Serialization。
通过在 Entity 实体中,使用装饰符使用此功能:
- @Exclude() 过滤字段。但是过滤字段只支持对象实例。如果直接 new 的对象,则不支持。
- @Expose() 将方法返回添加至数据中
- @Transform() 通过方法定义数据的返回
- @SerializeOptions() 通过 options 配置不同的过滤规则
Stream File 文件流
向客户端发送文件
@Controller("file")
export class FileController {
@Get()
getFile(@Res() res: Response) {
const file = createReadStream(join(process.cwd(), "package.json"));
file.pipe(res);
}
}
Task Scheduling 定时任务
类似 Linux crontab 的功能,但是编程性更高。
Queue 队列
队列可以在后台处理长时间的运行的任务。NextJS 使用 Bull 框架和 Redis 组成队列系统。
安装
$ npm install --save @nestjs/bull bull
$ npm install --save-dev @types/bull
初始化模块
app.module.tsJS;
import { Module } from "@nestjs/common";
import { BullModule } from "@nestjs/bull";
@Module({
imports: [
BullModule.forRoot({
redis: {
host: "localhost",
port: 6379,
},
}),
],
})
export class AppModule {}
注册队列
BullModule.registerQueue({
name: "audio",
});
Producers
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { InjectQueue } from '@nestjs/bull';
@Injectable()
export class AudioService {
constructor(@InjectQueue('audio') private audioQueue: Queue) {}
add() {
const job = await this.audioQueue.add({
foo: 'bar',
});
}
}
Consumers
import { Processor, Process } from "@nestjs/bull";
import { Job } from "bull";
@Processor("audio")
export class AudioConsumer {
@Process()
async transcode(job: Job<unknown>) {
let progress = 0;
for (i = 0; i < 100; i++) {
await doSomething(job.data);
progress += 10;
await job.progress(progress);
}
return {};
}
}
Consumer 通过事件监听队列运行的各种状态,如开始、暂停、错误等状态。
还可以开启另外一个进程来运行队列任务。这样的好处是队列 crash 后,主进程仍然可运行。
Cookie/Session
express session,支持 Session
import * as session from "express-session";
// somewhere in your initialization file
app.use(
session({
secret: "my-secret",
resave: false,
saveUninitialized: false,
})
);
更换 session store 存储方式:
Event
提供简单的事件订阅模式,解耦代码。
$ npm i --save @nestjs/event-emitter
app.module.tsJS;
import { Module } from "@nestjs/common";
import { EventEmitterModule } from "@nestjs/event-emitter";
@Module({
imports: [EventEmitterModule.forRoot()],
})
export class AppModule {}
事件发送
constructor(private eventEmitter: EventEmitter2) {}
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent({
orderId: 1,
payload: {},
}),
);
事件监听
@OnEvent('order.created')
handleOrderCreatedEvent(payload: OrderCreatedEvent) {
// handle and process "OrderCreatedEvent" event
}
文件上传
Express.Multer.File 支持文件上传。使用 FileInterceptor。
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log(file);
}
HTTP Module
封装 axios 进行 http 调用。
@Module({
imports: [HttpModule],
providers: [CatsService],
})
export class CatsModule {}
@Injectable()
export class CatsService {
constructor(private httpService: HttpService) {}
findAll(): Observable<AxiosResponse<Cat[]>> {
return this.httpService.get('http://localhost:3000/cats');
}
}
Authentication 认证
结合 Passport 框架,使用 300 多种认证策略。前端使用时,配合 NestJS 的 Guard 。实际项目中,通过使用两种 Passport 策略配合。一种是 passport-local,使用账号密码方式进行登陆。登陆完成之后,返回 jwt token。二是 passport-jwt,根据前端的 jwt token 验证用户。
开发 web 应用
访问某个 URL 后,服务器返回 HTML 响应。 HTML 数据动态填充。这就是模板问题。目前所有的系统都是通过模板解析进数据填充后再返回给客户端。NestJS 提供 html 响应也是非常简单的,只需要从众多模板里面挑选一个渲染模板即可。NestJS 几乎支持目前市面上的所有 JS 渲染模板如 hub, pug 等。
部署相关
最简单的部署方式就是单机应用部署。在项目根目录中运行 npm run start
或 yarn start
即可。也可以使用 PM2 等工具,管理应用的进程。并可提供多个进程访问。PM2 类似 php 的 php-fpm。
单机部署
# 直接运行
yarn
yarn start
# PM2运行
yarn install pm2@latest -g
yarn build
pm2 start dist/main.js --name <application_name>
# PM2 开机自启动
pm2 startup systemd
pm2 save
我们也可以使用 Docker 进行部署。打包好 Docker 镜像之后,分发到集群中去。Docker 文件示例如下:
FROM node:16 as builder
# Create app directory
WORKDIR /app
# Install app dependencies
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
# Build app
COPY . .
RUN yarn build
# =========
FROM node:16 as production
# Add bash for testing
# RUN apk add --no-cache bash
# Create app directory
WORKDIR /app
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/.env.production ./.env
EXPOSE 3008
CMD [ "node", "dist/main" ]
总结
NestJS 确实非常方便,但是也有不小的缺点。
单体运用,很容易挂。如队列运行和主进程在同一进程。