app-generator-cli:面向 OpenAPI 与数据库的原子化代码生成工具
1. 项目概述这不是又一个脚手架而是一台“应用零件自动装配机”“Stop Writing Boilerplate. Start Building”——这句话我第一次看到时正卡在第7个内部管理后台的登录页样式调试上。React TypeScript Ant Design 的组合拳已经打了五遍每次新建项目都要手动创建src/layouts/,src/utils/request.ts,src/services/auth.ts, 配置eslint规则、prettier格式、husky提交钩子再把package.json里那堆重复的devDependencies版本号对齐……不是写业务是在给工程化流程做考古复原。app-generator-cli 就是那个突然按停了时间按钮的工具它不生成“一个完整项目”而是生成“你正在写的那个项目里此刻最缺的那一块结构”。它把“创建项目”这个动作从“开新坑”降维成“拧螺丝”——你提供接口定义OpenAPI/Swagger、数据库表结构SQL DDL 或 JSON Schema它就吐出带类型推导的 API Client、带 CRUD 模板的 React 页面组件、甚至带字段校验逻辑的 Form 表单代码。核心关键词app-generator-cli、boilerplate elimination、code generation、OpenAPI integration、type-safe scaffolding全部指向一个事实它解决的不是“从零开始”的问题而是“重复劳动中的认知损耗”问题。适合谁不是刚学create-react-app的新手而是每周要交付 2~3 个微服务前端、维护着 5 个内部系统的中高级前端工程师是后端写完 Swagger 文档后想让前端同事 5 分钟内拿到可运行页面的架构师更是被“统一技术栈”要求压得喘不过气却又要快速响应业务需求的技术负责人。它不替代你的思考但把思考的起点从“怎么搭架子”直接拉到“怎么填内容”。2. 核心设计思路拆解为什么放弃“全量脚手架”选择“按需切片生成”2.1 传统脚手架的三大死结app-generator-cli 全部绕开我用过不下 12 个主流脚手架工具从yeoman到plop再到各种公司自研的internal-scaffold它们失败的根本原因不是功能少而是“太完整”。比如一个典型的create-next-app模板会默认包含pages/api/,public/,next.config.js,tailwind.config.js等 8 个目录和 15 个配置文件。但现实是你今天要开发的是一个纯数据看板根本不需要pages/api/你用的是Ant Design而非Tailwind你连getStaticProps都没打算用。结果就是90% 的模板代码成了“视觉噪音”你得花 20 分钟删掉不需要的部分再花 30 分钟把删掉的路径从tsconfig.json和eslint配置里剔除——这比自己从npm init开始还累。app-generator-cli 的破局点在于“原子化生成”。它不预设项目结构只预设“生成单元”一个 API 接口 → 生成一个useQueryHook 类型定义一张数据库表 → 生成一个ListPage组件 EditForm组件 service层方法一个 OpenAPI 的schema定义 → 生成完整的 TypeScript Interface Zod Schema 表单验证规则。这种设计背后有三个硬核考量第一依赖解耦。传统脚手架把所有技术选型UI 库、状态管理、路由方案强绑定在一起一旦你某天想把Redux换成Jotai就得重装整个脚手架。而 app-generator-cli 的每个生成器generator都是独立 npm 包比如app-gen/react-query、app-gen/zod-form你可以只安装app-gen/ant-design-table完全不碰app-gen/mui-form。我上个月给一个金融客户做定制化开发他们强制要求使用DevExtremeUI 库我就只写了app-gen/devextreme-grid这一个生成器3 天就接入了现有体系没动一行旧代码。第二上下文感知。它不生成“静态模板”而是读取你当前项目的上下文。比如你执行app-gen api --openapi ./openapi.yaml --target src/api/它会自动扫描src/api/下已有的baseApi.ts文件识别出axios实例的配置方式、拦截器逻辑、错误处理约定然后生成的userApi.ts会无缝继承这些约定而不是另起炉灶写一套fetch。这种能力来自它的“Context Resolver”机制——它把项目根目录下的tsconfig.json、vite.config.ts、.eslintrc.cjs当作输入源动态解析出类型路径别名/types、构建别名/components、ESLint 插件配置确保生成的代码能直接tsc通过无需二次调整。第三增量式演进。这是最反直觉也最实用的设计。你不需要一次性生成整个项目可以今天生成用户管理模块下周生成订单模块下个月再为新接入的支付网关生成 SDK。所有生成物都遵循同一套命名规范如UserListPage.tsx、OrderDetailPage.tsx和目录结构src/pages/user/、src/pages/order/天然支持模块联邦Module Federation或微前端拆分。我们团队去年重构一个 5 年老系统就是用这种方式先用app-gen db --sql ./legacy.sql --target src/models/生成了 42 张表的 TypeORM Entity再逐个模块生成页面6 周内完成了 80% 的前端迁移旧代码和新生成代码共存了整整 3 个月零线上事故。2.2 “Boilerplate Elimination” 的真实含义消灭的是“无脑复制”不是“工程规范”很多人误以为“消除样板代码”就是删掉console.log(Hello World)这种废话。错。app-generator-cli 消灭的是那些“明知不该写但为了项目能跑起来不得不写”的代码。比如类型定义与 API 调用的双重维护后端改了一个字段名你得同时改interface User { name: string }和const res await axios.get(/api/user, { params: { name } })。app-generator-cli 用 OpenAPI 作为唯一真相源一次生成User类型和getUser函数字段名变更后只需重新运行app-gen api两处自动同步。表单验证逻辑的碎片化一个用户注册表单name字段在 UI 层用required校验在 Service 层用if (!name) throw new Error()校验在后端 Controller 层又用IsNotEmpty()校验。app-generator-cli 的zod-form生成器会基于 OpenAPI 的schema生成一个userSchema.ts里面包含ZodObject定义然后在EditForm.tsx中直接useForm({ schema: userSchema })在userService.ts中调用userSchema.parse(userData)三端验证逻辑同源。CRUD 模板的机械重复ListPage里的分页逻辑、搜索框、操作列EditForm里的字段映射、提交状态管理、错误提示。这些逻辑高度模式化但手工编写极易出错比如漏掉onSuccess回调导致列表不刷新。app-generator-cli 的react-table生成器会根据数据库表的PRIMARY KEY、NOT NULL、DEFAULT约束自动生成带useTable集成的ListPage其中columns数组的accessorKey直接映射表字段cell渲染函数根据字段类型DATE→formatDateBOOLEAN→Switch智能选择连onRowClick的跳转路由都按约定生成/user/${row.id}/edit。这种设计让“规范”从“文档里的要求”变成了“代码生成的结果”。当所有新模块都由同一套生成器产出git diff里就再也看不到// TODO: add loading state这样的注释因为加载态逻辑已固化在生成模板中。这才是真正的工程提效——不是让你写得更快而是让“不该写的代码”彻底消失。3. 核心细节与实操要点从零启动一个可落地的生成工作流3.1 环境准备与基础配置5 分钟完成“生成中枢”搭建app-generator-cli 本身是一个 CLI 工具但它的威力不在于命令本身而在于其可扩展的生成器生态。部署一个生产级工作流关键不在安装而在“配置对齐”。以下是我在 3 个不同规模项目中验证过的最小可行配置首先全局安装 CLI注意不要用sudo npm install -g避免权限问题npm install -g app-generator-cli # 或使用 npx 方式避免全局污染推荐 npx app-generator-clilatest --version接着初始化项目专属配置。在项目根目录创建app-gen.config.ts这是整个工作流的“大脑”// app-gen.config.ts import { defineConfig } from app-generator-cli; export default defineConfig({ // 1. 指定生成目标告诉 CLI 你的项目结构约定 targets: { api: ./src/api/, // API 代码放这里 pages: ./src/pages/, // 页面组件放这里 models: ./src/models/, // 数据模型放这里 services: ./src/services/, // 业务服务放这里 }, // 2. 配置上下文感知让生成器读懂你的项目 context: { // 自动读取 tsconfig.json 的 paths 别名 tsConfigPath: ./tsconfig.json, // 识别 Vite 构建配置中的 alias viteConfigPath: ./vite.config.ts, // 如果用 ESLint指定配置路径以继承规则 eslintConfigPath: ./.eslintrc.cjs, }, // 3. 注册自定义生成器关键 generators: [ // 使用官方维护的 React Query 生成器 app-gen/react-query, // 使用社区版 Ant Design 表单生成器 app-gen/ant-design-form, // 加载本地自定义生成器见 3.2 节 ./generators/custom-db-generator, ], });提示app-gen.config.ts必须导出default且必须是 TypeScript 文件。CLI 启动时会自动加载此文件如果找不到会回退到内置默认配置仅支持基础功能。我见过太多团队卡在这一步——他们把配置放在app-gen.json里结果 CLI 完全无视生成的代码路径全是./src/而非./src/pages/最后归咎于工具“不灵活”。最关键的一步是targets配置。它不是简单的路径映射而是定义了“生成契约”。比如你设pages: ./src/pages/那么所有生成页面的命令如app-gen page --name user都会严格遵守此路径并自动创建./src/pages/user/目录。更妙的是它支持动态路径pages: (name) \./src/modules/${name}/pages这样app-gen page --name dashboard就会生成到./src/modules/dashboard/pages/完美适配模块化架构。3.2 官方生成器深度解析哪些能直接用哪些必须魔改app-generator-cli 的官方生成器仓库app-gen/*目前有 7 个主力包但实际项目中我只无脑信任 3 个另外 4 个必须动手改造。这是基于 18 个月、47 个项目的真实踩坑总结生成器名称可用性必须改造点改造耗时我的建议app-gen/react-query★★★★★无0 分钟官方维护最勤OpenAPI 解析准确率 99.2%支持x-enum-varnames扩展生成的useQuery自动处理staleTime和cacheTime。直接npm install app-gen/react-query即可。app-gen/zod-form★★★★☆需覆盖formFieldMap15 分钟默认将string映射为Input但业务中email字段需要EmailInputpassword需要PasswordInput。在app-gen.config.ts中添加zodForm: { formFieldMap: { email: EmailInput, password: PasswordInput } }即可。app-gen/ant-design-table★★★☆☆需重写columnRenderer45 分钟默认date字段渲染为Text但 AntD 的DatePicker需要render: (text) DatePicker value{moment(text)} /。必须在本地generators/antd-table.ts中重写getColumnDef方法注入moment依赖和格式化逻辑。app-gen/typeorm-entity★★☆☆☆需兼容 NestJS 的InjectRepository2 小时默认生成Entity()类但 NestJS 项目要求InjectRepository(User)能正确注入。必须修改生成逻辑在User.entity.ts顶部添加export const USER_REPOSITORY_TOKEN USER_REPOSITORY;并在Entity()下方添加Injectable()和constructor(Inject(USER_REPOSITORY_TOKEN) private readonly repository: RepositoryUser) {}。app-gen/nestjs-controller★☆☆☆☆路由前缀与模块结构冲突1 天默认Controller(user)但大型 NestJS 项目要求Controller(admin/user)。且生成的UserModule不会自动导入UserEntity。必须重写整个模块生成器集成NestFactory.createApplicationContext的模块发现逻辑。注意所有自定义生成器必须导出Generator类型对象且实现generate方法。官方文档里藏着一个致命陷阱generate方法的context参数包含了projectRoot项目根路径、configapp-gen.config.ts 内容、options命令行参数但不包含fs操作能力。你不能在generate里直接fs.writeFileSync必须返回一个File[]数组由 CLI 主进程统一写入。我曾因此浪费 3 小时调试“文件没生成”最后发现是生成器返回了undefined而非[{ path: xxx, content: xxx }]。3.3 实战案例从一张 MySQL 表到可运行的 CRUD 页面含避坑清单我们以一个真实的电商后台表product为例演示如何 10 分钟内生成完整页面。假设表结构如下MySQLCREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 商品名称, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 价格, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1-上架0-下架, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;步骤 1生成 TypeORM Entity为后续页面提供数据模型# 将 SQL 导出为 JSON Schema推荐用开源工具 sql-to-json-schema npx sql-to-json-schema --input ./product.sql --output ./product.schema.json # 运行生成器假设已配置好 app-gen/typeorm-entity npx app-generator-cli db --schema ./product.schema.json --target src/models/生成的Product.entity.ts关键片段Entity(product) export class Product { PrimaryGeneratedColumn(bigint) id: string; Column({ type: varchar, length: 100 }) name: string; Column({ type: decimal, precision: 10, scale: 2, default: () 0.00 }) price: number; Column({ type: tinyint, default: 1 }) status: number; Column({ type: datetime, default: () CURRENT_TIMESTAMP }) created_at: Date; }实操心得sql-to-json-schema工具对DEFAULT CURRENT_TIMESTAMP解析有 Bug会生成default: CURRENT_TIMESTAMP字符串而非() CURRENT_TIMESTAMP。我的解决方案是在app-gen.config.ts的typeormEntity配置中添加customDefaults: { CURRENT_TIMESTAMP: () CURRENT_TIMESTAMP }让生成器自动修复。步骤 2生成 API Service连接后端假设后端 Swagger 地址为https://api.example.com/openapi.json其中包含/product相关接口。先下载 OpenAPI 文件curl -o openapi.json https://api.example.com/openapi.json然后生成npx app-generator-cli api --openapi ./openapi.json --target src/services/product/ --tag product生成的productService.ts会包含getProductList(params: ProductListParams)自动推导params类型为ZodObject支持page、size、name等搜索字段createProduct(data: ProductCreateDto)data类型精确到每个字段name: string,price: number,status: 1 | 0updateProduct(id: string, data: ProductUpdateDto)id类型为string因 Entity 中id是string。步骤 3生成 React 页面核心价值爆发点npx app-generator-cli page --name product --entity Product --service ./src/services/product/ --target src/pages/product/此命令会生成 4 个文件ListPage.tsx带搜索框、分页、状态标签status: 1 → 上架、操作列编辑/删除的完整表格EditPage.tsx带字段校验name必填price 0、提交按钮状态管理loading/disabled的表单EditForm.tsx纯表单组件可被EditPage和CreatePage复用index.ts统一导出方便App.tsx中import { ProductListPage } from /pages/product。ListPage.tsx的关键逻辑自动生成// 自动生成的列定义status 字段自动渲染为 Tag const columns useMemoColumnDefProduct[](() [ { accessorKey: name, header: 商品名称, }, { accessorKey: price, header: 价格, cell: ({ row }) ¥${row.original.price.toFixed(2)}, }, { accessorKey: status, header: 状态, cell: ({ row }) ( Tag color{row.original.status 1 ? green : red} {row.original.status 1 ? 上架 : 下架} /Tag ), }, { id: actions, header: 操作, cell: ({ row }) ( Space Button sizesmall onClick{() navigate(/product/${row.original.id}/edit)} 编辑 /Button Button sizesmall danger onClick{() handleDelete(row.original.id)} 删除 /Button /Space ), }, ], []);避坑清单字段类型映射失真tinyint在 MySQL 中常被用作布尔值但sql-to-json-schema会生成type: integer导致生成的status类型为number而非1 | 0。解决方案在product.schema.json中手动修改status字段的enum为[0, 1]并添加type: integer。日期格式不一致created_at在数据库是datetime但前端显示需要YYYY-MM-DD HH:mm:ss。app-gen/ant-design-table默认不处理需在ListPage.tsx的cell函数中手动formatDate(row.original.created_at)。更好的方案是在生成器配置中添加dateFormatter: (date) moment(date).format(YYYY-MM-DD HH:mm:ss)。路由参数类型错误navigate(/product/${id}/edit)中的id是string但EditPage.tsx的useParams默认解析为string | undefined。生成器会自动添加!非空断言const { id } useParams{ id: string }();但若路由未传id页面会崩溃。我的补丁是在EditPage.tsx顶部添加if (!id) return Navigate to/product /;—— 这个逻辑不会被生成必须手动加。4. 实操过程与核心环节实现打通“设计-生成-集成”全链路4.1 OpenAPI 驱动的 API 生成如何让后端文档真正活起来OpenAPI 是 app-generator-cli 的“燃料”但绝大多数团队的 OpenAPI 文档处于“能生成但不好用”的状态。我梳理出 3 个让文档从“摆设”变“引擎”的实操技巧技巧 1用x-codegen扩展标记生成意图OpenAPI 的x-*扩展字段是官方预留的自定义空间。我们在paths下添加x-codegen明确告诉生成器“这个接口要生成什么”paths: /product: get: x-codegen: generate: true # 是否生成此接口 as: list # 生成为列表查询影响 hooks 命名 pagination: true # 启用分页参数自动添加 page/size parameters: - name: name in: query schema: type: string x-codegen: search: true # 此参数为搜索字段影响 ListPage 搜索框生成器读取x-codegen后会为GET /product生成useProductListQueryHook而非泛用的useQuery在ListPage.tsx的搜索表单中自动添加name输入框useProductListQuery的params类型中page和size字段被标记为optional而name为string | undefined。技巧 2Schema 复用消除冗余定义后端常把Product的创建和更新 DTO 分开定义导致生成器产生ProductCreateDto和ProductUpdateDto两个几乎相同的类型。我们用$ref统一引用components: schemas: ProductBase: type: object properties: name: type: string price: type: number ProductCreateDto: allOf: - $ref: #/components/schemas/ProductBase - type: object properties: status: type: integer enum: [0, 1] ProductUpdateDto: allOf: - $ref: #/components/schemas/ProductBase - type: object properties: id: type: stringapp-generator-cli 的zod-form生成器会智能合并ProductBase生成productBaseSchemaProductCreateDto生成productCreateSchema productBaseSchema.extend({ status: z.number().int().min(0).max(1) })避免类型爆炸。技巧 3错误响应的自动化处理OpenAPI 的responses常忽略400、401等错误码导致生成的代码没有错误处理逻辑。我们在responses中显式定义responses: 400: description: 请求参数错误 content: application/json: schema: $ref: #/components/schemas/ValidationError 401: description: 未授权 content: application/json: schema: $ref: #/components/schemas/UnauthorizedError生成器会据此在useProductListQuery的onError回调中自动注入错误处理逻辑onError: (error) { if (error.response?.status 400) { // 自动调用 setError将 ValidationError 显示在表单上 setError(root, { message: error.response.data.message }); } if (error.response?.status 401) { // 自动跳转登录页 navigate(/login); } },4.2 数据库驱动的页面生成从 SQL DDL 到 React 组件的精准映射数据库表结构是页面逻辑的“地基”但sql-to-json-schema工具的输出往往过于宽泛。我们需要在生成前用schema-transformer工具进行“语义增强”步骤 1为字段添加业务语义注释在 MySQL 的COMMENT中不只是写“商品名称”而是写结构化注释name varchar(100) NOT NULL COMMENT 【label:商品名称】【search:true】【required:true】, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 【label:销售价格】【unit:¥】【min:0.01】, status tinyint NOT NULL DEFAULT 1 COMMENT 【label:上架状态】【enum:1上架,0下架】【display:tag】,步骤 2用 transformer 提取语义编写transformer.tsimport { readFileSync, writeFileSync } from fs; import { parse } from jsonc-parser; const schema parse(readFileSync(./product.schema.json, utf8)); schema.properties.name[x-display] { label: 商品名称, search: true, required: true, }; schema.properties.price[x-display] { label: 销售价格, unit: ¥, min: 0.01, }; schema.properties.status[x-display] { label: 上架状态, enum: { 1: 上架, 0: 下架 }, display: tag, }; writeFileSync(./product.enhanced.json, JSON.stringify(schema, null, 2));步骤 3生成器读取增强语义app-gen/ant-design-table生成器会读取x-display字段label→ 表格列头文字search: true→ 在ListPage搜索表单中添加该字段enum→ 渲染为Tag或Selectunit→ 在EditForm的InputNumber组件后添加addonAfter¥。最终生成的EditForm.tsx片段Form.Item label销售价格 nameprice rules{[{ required: true, message: 请输入销售价格 }, { min: 0.01, message: 价格不能小于0.01 }]} InputNumber addonAfter¥ style{{ width: 100% }} / /Form.Item Form.Item label上架状态 namestatus rules{[{ required: true, message: 请选择状态 }]} Select options{[ { value: 1, label: 上架 }, { value: 0, label: 下架 } ]} / /Form.Item4.3 生成代码的集成与定制如何让“机器写的代码”像人写的生成的代码不是终点而是起点。我总结出 3 层集成策略确保生成物无缝融入现有工程第 1 层Git Hooks 自动化校验在package.json中添加scripts: { precommit: lint-staged, prepare: husky install }, lint-staged: { src/**/*.{ts,tsx}: [ eslint --fix, prettier --write ], // 关键对生成的文件只运行 Prettier跳过 ESLint避免规则冲突 src/generated/**/*.{ts,tsx}: [ prettier --write ] }并在.prettierignore中添加src/generated/防止 Prettier 格式化破坏生成器的代码结构如 JSX 换行。第 2 层TypeScript Path Mapping 精准定位在tsconfig.json中为生成的代码设置专用路径别名{ compilerOptions: { baseUrl: ., paths: { generated/*: [src/generated/*], pages/*: [src/pages/*], services/*: [src/services/*] } } }这样在ListPage.tsx中可以import { getProductListQuery } from generated/product/api清晰区分“手写”与“生成”代码。第 3 层运行时定制化注入最高阶技巧生成器无法预知所有业务逻辑但我们可以预留“钩子”。在app-gen.config.ts中export default defineConfig({ // ... generators: [ app-gen/ant-design-table, ], // 注入运行时定制逻辑 runtime: { // 在 ListPage 生成后自动添加自定义列 afterListPageGenerate: (fileContent, options) { // 如果是 product 页面添加“销量”列 if (options.name product) { const salesColumn { accessorKey: sales, header: 销量, cell: ({ row }) row.original.sales || 0, }, ; return fileContent.replace( /const columns useMemoColumnDef.*?\(\[\n/, const columns useMemoColumnDef${options.entity}([\n${salesColumn} ); } return fileContent; } } });此逻辑在ListPage.tsx文件写入磁盘前执行相当于“生成后处理”完美解决“80% 自动生成 20% 人工定制”的平衡问题。5. 常见问题与排查技巧实录那些只有亲手踩过才知道的坑5.1 生成器不生效90% 是配置路径没对齐现象执行npx app-generator-cli page --name user控制台显示Generated 0 files或生成到错误路径如./src/而非./src/pages/。排查步骤检查app-gen.config.ts是否存在且导出default# 在项目根目录运行 node -e console.log(require(./app-gen.config.ts).default)如果报错Cannot find module或输出undefined说明路径或导出错误。验证 CLI 是否读取到配置添加调试日志到app-gen.config.tsconsole.log([DEBUG] app-gen.config.ts loaded); export default defineConfig({ /* ... */ });再次运行命令看控制台是否输出[DEBUG]。没有说明 CLI 根本没加载此文件。确认 CLI 版本与配置文件语法匹配app-generator-cli1.x要求app-gen.config.ts而0.x版本只认app-gen.json。运行npx app-generator-cli --version查看版本再查对应版本的文档。终极方案强制指定配置路径npx app-generator-cli page --name user --config ./app-gen.config.ts如果此命令成功证明是自动加载失败需检查app-gen.config.ts的export default语法或 Node.js 版本兼容性TS 配置需 Node.js 16。5.2 类型错误Property xxx does not exist on type InferType...现象生成的EditForm.tsx中form.watch(name)报 TS 错误提示name字段不存在。根本原因Zod Schema 与 React Hook Form 的useForm类型推导不匹配。常见于两种情况OpenAPI Schema 中字段名含-或.如first-nameZod 生成first_name但useForm期望first-name。解法在 OpenAPI 中用x-field-name扩展components: schemas: User: properties: first-name: type: string x-field-name: firstName # 生成时用 firstName但 API 传输仍用 first-nameZod Schema 的partial()与required()冲突生成器为可选字段生成z.string().optional()但useForm的watch方法需要明确的