掘金 后端 ( ) • 2024-04-30 15:01

选择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文件

image.png

并且,还获得类型安全的数据类,心智负担又降低了。

image.png

你可以直接使用下面的语法快速查询你的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还在编译中。

image.png

实现田园敏捷

这里没有任何褒义,这里的田园敏捷指的是工作中一种常见的工作状态,功能急需上线,期望用最低的代价同时完成功能开发又不失去太多可维护性。怎么办呢,只保留最核心的测试代码。让我们看看所有可写的测试类型:

  • 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)