选择nestJS的理由
这是一篇来自原spring boot kotlin
开发者的安利。作者使用spring boot kotlin
和DDD模式开发后端好几年了,最近一段时间在新项目中应用了nestjs
,对比之下开发效率大幅提升。对于一个普通的业务系统开发,CRUD加连表查询是最常规的操作,不同接口做的事也就是将DAO和DTO进行存储转换:
- DAO data access object 与数据库一行记录对应,在jpa中,DAO用
@Entity
注解标记。在ddd设计模式中,DAO class常常包含一些实例方法已使得更加代码更加内聚,例如下面这个AppUser
类直接包含了一个verifyPassword
方法,这样在用户登录的时候,直接user.verifyPassword(password)
即可直接获得结果,避免将业务代码分散在不同的service中。
@Entity
class AppUser(
@Id()
@Column()
val uid: Int,
@Column()
var mobile: String,
@Column()
var bcryptPassword: String,
) {
fun verifyPassword(password: String): Boolean {
return BCryptPasswordEncoder().matches(password, bcryptPassword)
}
}
对于数据表结构的管理,也就是database migration
,一般还需要额外引入flyway
这种三方库,或是手工升级。
- DTO data transfer object 接口传输过程中使用的传输对象,是对原始DAO的封装。以上面的
AppUser
为例,传输过程中,一方面是由于包含不便于暴露给前端的字段bcryptPassword
,另一方面从DDD的角度来说,对外提供领域层的模型会导致代码耦合严重难以维护。对于jpa来说,Entity包含OneToOne OneToMany对其他对象的引用,处理将变得更加复杂,里面的坑不计其数,最佳实践就是麻烦一点,来个DTO。幸好,在kotlin
中做一个转换并不太麻烦:
data class AppUserDTO(
val uid: Int,
val mobile: String,
)
@Entity
class AppUser(
/********省略部分代码*********/
) {
fun toDTO(): AppUserDTO {
return AppUserDTO(
uid = uid,
mobile = mobile,
)
}
}
当字段变多时,这依旧是很麻烦的事。
让我们看看nestjs+prisma
是怎么让开发变轻松的。
PRISMA 易用性
对于数据访问层,prisma
引入了一个让人眼前一亮的操作,它使用专用的prisma.schema
文件来描述数据的格式,并提供cli工具来同步数据库并生成对应的migration文件:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model WechatAccount {
openid String @id
sessionKey String
unionId String?
appUser AppUser?
}
model AppUser {
uid Int @id @default(autoincrement())
mobile String? @unique
bcryptPassword String?
wxOpenid String? @unique
wechat WechatAccount? @relation(fields: [wxOpenid], references: [openid])
@@index([wxOpenid])
}
上面代码创建了一个AppUser
和一个与之对应的WechatAccount
,通过指定relationMode="prisma"
使用非外键的关联模式,运行 npx prisma migrate dev --name init
命令,会自动在prisma目录下创建出对应的migration文件
并且,还获得类型安全的数据类,心智负担又降低了。
你可以直接使用下面的语法快速查询你的appUser:
const user = await this.prisma.appUser.findUnique({
where: {
mobile,
},
});
但是有个小问题,返回的AppUser
对象是prisma
编译生成的client
代码,我们不能对他扩展方法,像kotlin
中那样直接调用verifyPassword
方法,这时候就要祭出mixin
大法了。
export const appUserMixin = {
async validatePassword(pass: string) {
return bcrypt.compare(pass, (this as AppUser).bcryptPassword);
},
};
简洁,但仍然强类型
对于DTO,尽管kotlin
相比java
已经足够简洁,但是我更喜欢lodash
中
pick(user, ['uid', 'mobile'])
的方式,不仅能够少写两行代码,同时还能享受到typescript
的类型检查。众所周知,代码越长,产生问题的可能性就越多。
速度
这里的速度,说的是编译速度。nest
使用fastify
作为运行引擎,就一个字,快。编译速度快,运行得到结果也够快。同样规模功能的单元测试,可能nest
已经全部运行完了,kotlin
还在编译中。
实现田园敏捷
这里没有任何褒义,这里的田园敏捷指的是工作中一种常见的工作状态,功能急需上线,期望用最低的代价同时完成功能开发又不失去太多可维护性。怎么办呢,只保留最核心的测试代码。让我们看看所有可写的测试类型:
- service test: 单元测试,mock所有依赖的类,spyOn目标方法已确认方法得到适当的调用。
- controller test: 单元测试,mock所有依赖的类,spyOn目标方法已确认方法得到适当的调用。
- e2e test: 集成测试,启动完整的web服务,本地发起web请求验证接口正确性。一般使用test container生成真实数据库实例。
对于单元测试来说,mock依赖项相当麻烦,尤其是当一个service依赖项比较多的时候。而且大多数时候,逻辑是直线性的,一个创建实例的接口,函数内只有一条prisma.item.create
语句,mock它意义不大。直接写集成测试没问题,但是通过http client方式写测试,没有了类型检查的辅助,写起来还是不够方便。那把两者结合一下的单元测试,就是很符合需要的田园敏捷了。
直接上代码:
describe('TodoController', () => {
let module: TestingModule;
let controller: TodoController;
let prisma: PrismaClient;
let todoId: number;
const session1: AppSession = {
userId: 1,
token: 'test',
mobile: '12345678901',
expires: 0,
};
const session2: AppSession = {
userId: 2,
token: 'test',
mobile: '12345678902',
expires: 0,
};
beforeAll(async () => {
module = await await Test.createTestingModule({
imports: [AppModule],
}).compile();
controller = module.get<TodoController>(TodoController);
prisma = module.get<PrismaClient>(PrismaClient);
await prisma.$executeRaw`TRUNCATE TABLE "Todo"`;
});
afterAll(async () => {
await module.close();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should create todo', async () => {
const todo = await controller.create(session1, { title: 'test1', uid: undefined });
expect(todo.title).toBe('test1');
todoId = todo.id;
await controller.create(session1, { title: 'test2', uid: undefined });
await controller.create(session1, { title: 'test3', uid: undefined });
await controller.create(session1, { title: 'test4', uid: undefined });
await controller.create(session1, { title: 'test5', uid: undefined });
await controller.create(session2, { title: 'test6', uid: undefined });
await controller.create(session2, { title: 'test7', uid: undefined });
await controller.create(session2, { title: 'test8', uid: undefined });
});
it('should update todo', async () => {
await controller.update(session1, todoId, { title: 'test updated', completed: true, uid: undefined, id: undefined });
});
it('should list todos', async () => {
const todos = await controller.list(session1, {
page: 1,
size: 10,
sort: 'id',
order: 'asc',
uid: undefined,
});
expect(todos.length).toBe(5);
expect(todos[0].title).toBe('test updated');
expect(todos[0].id).toBe(todoId);
const todos2 = await controller.list(session2, {
page: 1,
size: 10,
sort: 'id',
order: 'asc',
uid: undefined,
});
expect(todos2.length).toBe(3);
const todos3 = await controller.list(session1, {
page: 1,
size: 10,
sort: 'id',
order: 'asc',
title: 'test2',
uid: undefined,
});
expect(todos3.length).toBe(1);
const todos4 = await controller.list(session1, {
page: 1,
size: 10,
sort: 'id',
order: 'desc',
completed: true,
uid: undefined,
});
expect(todos4.length).toBe(1);
});
it('should delete todo', async () => {
await controller.delete(session1, todoId);
const todos = await controller.list(session1, {
page: 1,
size: 10,
sort: 'id',
order: 'asc',
uid: undefined,
});
expect(todos.length).toBe(4);
});
});
这里直连了专门为测试准备的本地测试库,在beforeAll
中清空了数据库。也可以写一个脚本使用test container起一个临时库使用。
自用脚手架
最后,分享自用的脚手架工程,文中代码均源自此工程。添加了常用的业务和功能模块,长期维护不断添加实用功能,clone 下来删掉不需要的模块,新项目可以火速上线了。yxq2233234/nest-starter: nestjs脚手架 (github.com)