Yuzhe's Blog

yuzhes

@faster-crud:消灭 NestJS 中的 CRUD 样板代码

@faster-crud:消灭 NestJS 中的 CRUD 样板代码

GitHub:bkmashiro/nest-faster-crud
npm:@faster-crud/core@faster-crud/nest@faster-crud/typeorm@faster-crud/vue


问题所在

在 NestJS 里写 CRUD 是一套固定仪式。每个新资源,你要按几乎相同的顺序写几乎相同的东西:

  1. 一个 TypeORM 实体
  2. 一个带 @IsString()@IsOptional() 等装饰器的 CreateDto
  3. 一个 UpdateDto(通常是 PartialType(CreateDto),但有例外)
  4. 一个带有 findAllfindOnecreateupdateremove 的 service
  5. 一个用 @Get@Post@Patch@Delete 串联起来的 controller
  6. 用于 Swagger 的 @ApiTags@ApiOperation@ApiResponse
  7. 一个导入和导出所有内容的 module

然后对下一个资源再来一遍。再下一个。写到第三个资源时,你已经不在思考自己在构建什么了——纯靠本能,复制粘贴,查找替换。

我在这个循环里待了够久了,所以我写了一条出路。


解决方案:defineCrud<T>()

@faster-crud 是一个通过单一工厂调用生成完整 CRUD 技术栈的库。入口点是 defineCrud<T>(opts)

import { defineCrud } from '@faster-crud/nest';
import { User } from './user.entity';

const UserCrud = defineCrud<User>({
  entity: User,
  createFields: ['name', 'email', 'role'],
  updateFields: ['name', 'role'],
  searchFields: ['name', 'email'],
});

@Module({
  imports: [TypeOrmModule.forFeature([User]), UserCrud.module],
  controllers: [...UserCrud.controllers],
  providers: [...UserCrud.providers],
})
export class UserModule {}

就这些。defineCrud 返回一个完整串联好的 NestJS 模块,包含:

所有字段都有类型。createFieldsupdateFieldssearchFields 的类型是 (keyof T)[]——你在这里有 TypeScript 自动补全,拼写错误会变成编译错误。


架构:三层结构

库被分成三个包,以保持关注点分离,并为未来的框架可移植性做准备。

@faster-crud/core(v0.1.0)

零 NestJS 依赖。定义 CrudDefinition<T> 接口、字段元数据模型和 ResourceMeta schema。这是系统框架无关的核心。

因为 core 没有框架依赖,同样的定义格式理论上可以适配到 Hono、Fastify 或任何其他 Node.js 框架。NestJS 只是第一个适配器。

@faster-crud/nest(v0.1.1)

NestJS 集成层。接收 CrudDefinition<T> 并使用 NestJS 的 DynamicModule API 生成一个可直接导入的模块。处理通过 reflect-metadata 生成 DTO 类、controller 类构建和 Swagger 集成。

一个不太显眼的细节:NestJS 需要 DTO 类(而非仅仅是接口)来集成 validation pipe 和生成 Swagger schema。@faster-crud/nest 在运行时使用类工厂动态生成这些类,然后在将它们交给 NestJS 的 IoC 容器之前,用 class-validator@nestjs/swagger 元数据装饰它们。

@faster-crud/typeorm(v0.1.0)

TypeORM 适配器。提供由 TypeORM repository 支撑的 service 实现,实现 core 中的 CrudService<T> 接口。

// 内部,TypeORM 适配器大致如下:
class TypeOrmCrudService<T> implements CrudService<T> {
  constructor(private readonly repo: Repository<T>) {}

  findAll(query: SearchQuery<T>): Promise<T[]> {
    return this.repo.find({ where: buildWhere(query) });
  }
  // ... create, update, remove
}

将 TypeORM 替换为 Prisma 或其他 ORM,意味着编写一个满足 CrudService<T> 的新适配器——其余技术栈保持不变。


/__crud/meta 自省端点

每次 defineCrud 调用还会注册一个 GET /__crud/meta 端点,返回该资源的完整 ResourceMeta JSON:

{
  "resource": "User",
  "fields": [
    { "name": "name", "type": "string", "required": true, "label": "名称" },
    { "name": "email", "type": "string", "required": true, "label": "邮箱" },
    { "name": "role", "type": "string", "required": false, "label": "角色" }
  ],
  "endpoints": {
    "list": "GET /users",
    "get": "GET /users/:id",
    "create": "POST /users",
    "update": "PATCH /users/:id",
    "delete": "DELETE /users/:id"
  }
}

这个 meta 端点是连接前端包的桥梁。前端不需要手动维护一个镜像后端 DTO 的表单 schema,只需拉取这个文档并相应地渲染即可。


前端:@faster-crud/vue(v0.1.1)

Vue 包消费 meta 端点,提供一个用于构建类型化表单/表格配置的 builder API。

import { fetchCrudMeta, field } from '@faster-crud/vue';

const userMeta = await fetchCrudMeta('https://api.example.com/users');

const formConfig = userMeta
  .field('name').label('全名').type('text').required().rules([
    { min: 2, message: '名称太短' }
  ])
  .field('email').label('邮箱').type('email').required()
  .field('role').label('角色').type('select').options(['admin', 'user'])
  .build();

builder 链根据 fetchCrudMeta 返回的字段名进行类型检查。引用不存在的字段是 TypeScript 错误。

formConfig 可以传给通用的 <CrudTable><CrudForm> Vue 组件来渲染 UI。这在配置层面是故意与展示无关的——你可以换入自己的组件。


当前状态


为什么不用已有的方案?

NestJS 有现成的 CRUD 生成器——@nestjsx/crud 是最知名的。在开始之前我认真研究过它。

我遇到的问题:

@faster-crud 更小,更有主见,但它所做的事情都是端到端完全类型安全的。


我会做什么不同的事

最大的架构遗憾是 @faster-crud/nest 的动态 DTO 类生成有些脆弱。NestJS 的 ValidationPipe 和 Swagger 都依赖于在类定义时就存在的类元数据装饰器,而不是在运行时。我通过在类构建后调用 Reflect.defineMetadata 来绕过这个问题——它确实有效,但这是一些关键的魔法,在 NestJS 内部发生变化时可能会崩溃。

如果重新开始,我会考虑使用 zod schema 作为真实来源(而不是实体键自省),并从中同时生成运行时验证和 Swagger schema。@anatine/zod-openapi 的方式更加简洁。


代码在 GitHub 上。欢迎 PR,特别是关于 Prisma 适配器和代码生成 CLI 的部分。