@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 是一套固定仪式。每个新资源,你要按几乎相同的顺序写几乎相同的东西:
- 一个 TypeORM 实体
- 一个带
@IsString()、@IsOptional()等装饰器的CreateDto - 一个
UpdateDto(通常是PartialType(CreateDto),但有例外) - 一个带有
findAll、findOne、create、update、remove的 service - 一个用
@Get、@Post、@Patch、@Delete串联起来的 controller - 用于 Swagger 的
@ApiTags、@ApiOperation、@ApiResponse - 一个导入和导出所有内容的 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 模块,包含:
- 通过反射从实体字段类型派生的
CreateDto和UpdateDto - 由 TypeORM 适配器支撑的 service
- 带有
GET /users、GET /users/:id、POST /users、PATCH /users/:id、DELETE /users/:id的 controller - 每个端点上的 Swagger 装饰器
- 一个
GET /__crud/meta自省端点(下面详述)
所有字段都有类型。createFields、updateFields 和 searchFields 的类型是 (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。这在配置层面是故意与展示无关的——你可以换入自己的组件。
当前状态
- 阶段 1:Core + NestJS 集成 — 已完成。
defineCrud、DTO 生成、TypeORM 适配器、Swagger、测试。 - 阶段 2:前端 meta 消费者 — 已完成。带有
fetchCrudMeta和 builder 链的@faster-crud/vue。 - 阶段 3:代码生成 CLI — 已推迟。原计划是一个 CLI,读取代码库中的
defineCrud调用并生成静态 TypeScript 客户端代码(不需要运行时 meta 拉取)。这仍然是个好主意,但阶段 1+2 已经足够实用。
为什么不用已有的方案?
NestJS 有现成的 CRUD 生成器——@nestjsx/crud 是最知名的。在开始之前我认真研究过它。
我遇到的问题:
@nestjsx/crud与 TypeORM 的耦合方式使替换适配器并不简单- DTO 生成在调用点不是类型安全的(字段名是字符串,而非
keyof T) - 没有内置的前端 meta 桥梁——你仍然需要手动维护前端 schema
@faster-crud 更小,更有主见,但它所做的事情都是端到端完全类型安全的。
我会做什么不同的事
最大的架构遗憾是 @faster-crud/nest 的动态 DTO 类生成有些脆弱。NestJS 的 ValidationPipe 和 Swagger 都依赖于在类定义时就存在的类元数据装饰器,而不是在运行时。我通过在类构建后调用 Reflect.defineMetadata 来绕过这个问题——它确实有效,但这是一些关键的魔法,在 NestJS 内部发生变化时可能会崩溃。
如果重新开始,我会考虑使用 zod schema 作为真实来源(而不是实体键自省),并从中同时生成运行时验证和 Swagger schema。@anatine/zod-openapi 的方式更加简洁。
代码在 GitHub 上。欢迎 PR,特别是关于 Prisma 适配器和代码生成 CLI 的部分。