你经历过这种场景吗?新接一个项目,光搭目录、配数据库、写 CRUD 就要花掉大半天;复制粘贴上次的代码模板,全局替换变量名,总有那么一两个漏网之鱼,等到上线前才暴露出来。
我最早是纯手工搞这件事,后来做成半自动脚本,再后来干脆把整套思路整理成了一个命令行工具,名字叫 CodeMagicianT。它做的事很简单:你定义好模板,给它一份业务字段配置,它把模板里的占位符全部替换成真实内容,一次性生成完整的项目骨架或者某个业务模块。听起来不复杂,但真正把细节打磨到"团队里随便一个人都能放心用",中间踩了不少坑。
这篇文章不打算写成一份使用手册,我想把 CodeMagicianT 的完整设计思路、核心实现、实操过程和问题排查记录都摊开来讲。适合正在被重复代码折磨、想搞一套内部代码生成器但不知从哪下手的开发者,也适合想了解 CLI 工具设计细节的朋友。
1. 项目由来与核心设计思路
1.1 一个被重复劳动磨平耐心的人,决定自己写生成器
我在团队里主要负责后端服务,项目类型高度相似:Express 或者 Fastify 起 HTTP 服务,Sequelize 或者 Prisma 做 ORM,Redis 做缓存,再配上一堆用户、订单、商品之类的业务模块。每次新需求过来,最烦的不是业务逻辑,而是那些"长得都差不多"的模板代码。
一个月两个月还能忍,时间长了问题就出来了:字段命名不一致,有人用创建时间的英文名,有人用中文拼音;错误处理风格五花八门;接口返回结构各写各的。代码评审时提得最多的不是业务问题,而是这些本应该统一的模板问题。
后来我想明白一件事:团队的代码规范,与其写在文档里让每个人去记,不如直接固化到生成工具里。你执行一条命令,生成的代码天然就是规范的样子。CodeMagicianT 最早就是为了解决这个"规范落地"的问题。
1.2 功能边界:它不生成业务逻辑,只生成业务骨架
在设计初期,我犯过一个方向性错误,总想着能不能根据表结构把整个业务逻辑都自动写出来。试了一段时间后放弃了这个想法,原因是业务逻辑的变数太大,硬要自动化,生成的代码反而需要大量返工,不如只专注做一件事:把"项目骨架"和"标准 CRUD 接口层"这层确定性的东西自动化。
于是 CodeMagicianT 的定位就清晰了:
- 输入是模板目录加一份业务配置;
- 输出是一组渲染后的真实文件;
- 它本身不做任何业务层面的推断。
这个边界很重要。有了边界,工具才能保持简单和稳定;团队的复杂逻辑仍然由人来写,但重复的"搬运工作"完全交给工具。
1.3 命名里的那点私心
CodeMagicianT 这个名字拆开看:Code 指的是面向代码生成场景;Magician 代表"像变魔术一样从模板变出整块代码";末尾的 T 我取的是 TypeScript 和 Template 的双关。因为我最初就是给 TypeScript 项目写模板,同时整个工具的核心抽象又是"模板 + 配置 = 目标代码"。
这个名字也提醒我一个原则:工具越是看起来"魔法",内部实现越要透明可查。后面我会讲到,生成的每一步都支持 dry-run 预览,就是不想让使用者觉得这是个黑盒。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与关键技术选型
2.1 为什么选择 TypeScript + Node.js
做代码生成器,技术栈本身选择很多:Python、Go、Node.js 都能做。我最后选了 TypeScript + Node.js,原因有三个。
第一,团队技术栈统一。大家的日常开发以 TS 为主,维护这个工具没有额外的语言成本,新同事看代码上手也快。
第二,npm 生态里有非常成熟的命令行相关库。参数解析、交互式提问、文件操作、模板渲染都有现成方案,不用从零造轮子。
第三,模板解析和项目本身的"亲近度"最高。我们要生成的很大一部分就是 TypeScript 代码,工具和产物同属一个生态,处理导入导出、路径别名这些逻辑时不用跨语言理解上下文。
2.2 依赖清单和各自的作用
我梳理一下 CodeMagicianT 用到的核心依赖,每个都说一句"它在项目里是干嘛的",方便你按需取用:
- commander:负责命令行参数解析,定义
init、generate、list这些子命令; - prompts:实现交互式问答,在命令行里让用户选择模板、填写字段信息;
- ejs:模板渲染引擎,模板文件用
.ejs后缀,内部支持嵌入 JS 逻辑; - chalk:终端彩色输出,成功、警告、错误一眼区分;
- fs-extra:文件操作增强版,支持递归复制、更安全的写入;
- glob:匹配模板目录下的所有文件,用通配符支持多层目录递归;
- conf:本地配置文件管理,用来存模板路径、默认配置等;
- commander 和 prompts 配合使用,命令行参数优先,缺省时再走交互式提问。
2.3 目录结构与模块划分
工具本身的代码结构,我按"单命令模式"设计:
text复制code-magician/
├── bin/
│ └── magician.ts # 可执行入口
├── src/
│ ├── cli.ts # commander 命令注册
│ ├── commands/
│ │ ├── init.ts # 初始化模板目录
│ │ ├── generate.ts # 核心生成逻辑
│ │ └── list.ts # 查看可用模板
│ ├── core/
│ │ ├── render.ts # 模板渲染
│ │ ├── scanner.ts # 模板文件扫描
│ │ ├── writer.ts # 文件输出与覆盖控制
│ │ └── config.ts # 配置读写
│ ├── templates/ # 内置模板
│ └── utils/
│ ├── naming.ts # 命名转换:camelCase、PascalCase 等
│ └── logger.ts # 彩色日志
└── package.json
模块划分的原则是:命令层只做参数解析和流程编排,核心层只做单一职责的渲染、扫描、写入。这样每个文件都不大,出现问题能快速定位。
3. 核心实现细节:渲染引擎、参数收集与安全写入
3.1 CLI 入口与命令注册
CodeMagicianT 的可执行入口本质是一个 shebang 脚本加命令行注册。核心代码如下:
typescript复制#!/usr/bin/env node
import { Command } from 'commander';
import { initCommand } from './commands/init';
import { generateCommand } from './commands/generate';
import { listCommand } from './commands/list';
const program = new Command();
program
.name('code-magician')
.description('基于模板与配置的代码生成工具')
.version('1.0.0');
program
.command('init')
.description('初始化模板目录')
.option('-p, --path <path>', '模板目录路径', './templates')
.action(initCommand);
program
.command('generate')
.description('根据模板生成代码')
.requiredOption('-t, --template <name>', '模板名称')
.option('--dry-run', '仅预览生成结果,不写入文件')
.option('-c, --config <path>', '配置文件路径')
.action(generateCommand);
program
.command('list')
.description('列出所有可用模板')
.action(listCommand);
program.parse(process.argv);
这里有一个细节值得多说一句:requiredOption 比 option 更早地暴露参数缺失问题,用户不会等到交互式提问阶段才发现模板名称没传。CLI 工具设计的原则是"能早报错就不要晚报错",这一点在多人使用时体验差别很大。
3.2 交互式参数收集:命令行参数优先,缺省才提问
如果让用户每次敲一长串参数,工具的门槛就太高了。我采用了一个常见的策略:命令行参数优先,缺省时再由 prompts 提问补全。
typescript复制import prompts from 'prompts';
export async function collectOptions(cliOptions: any) {
const questions = [];
if (!cliOptions.template) {
questions.push({
type: 'select',
name: 'template',
message: '请选择模板',
choices: listTemplates(),
});
}
if (!cliOptions.fields) {
questions.push({
type: 'text',
name: 'fields',
message: '请输入字段列表,例如 name:string,email:string,age:number',
});
}
const answers = await prompts(questions);
return { ...cliOptions, ...answers };
}
交互式提问看起来是小事,但实际上影响工具的整体手感。我的经验是每一个问题都要有默认值,而且默认值要是"大多数场景下最合理的那个"。比如生成路径默认是 src/modules/{name},用户直接回车就能用,而不是被迫思考"这里应该填什么"。
3.3 命名转换:代码生成里最容易被忽视的细节
生成代码时,用户输入的是类似 user_profile 这样的字段名,但模板里在不同位置需要不同的命名风格:数据库列名可能是 user_profile,对象属性可能是 userProfile,类名可能是 UserProfile,文件名可能是 user-profile.ts。这一层转换如果做不好,生成的代码会到处是命名不一致。
我封装了 naming 工具类:
typescript复制export function toCamelCase(input: string): string {
return input.replace(/([-_]\w)/g, (g) => g[1].toUpperCase());
}
export function toPascalCase(input: string): string {
const camel = toCamelCase(input);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
export function toKebabCase(input: string): string {
return input.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
模板里不再写死变量名,而是调用这些转换函数。比如模板中写 <%= pascalCase(name) %>File,无论用户输入的是 user 还是 user_profile,输出都是规范的文件名。这个设计在后期维护中帮了大忙,新增一种命名需求时只需要在工具类里加一个函数。
3.4 模板渲染:ejs 加自定义 helpers
模板引擎我最终选了 ejs。原因是它对 JS 开发者最友好,模板里可以直接写循环和条件判断,理解成本低。看一下典型的模板片段:
ejs复制// model.template.ejs
import { Model, DataTypes } from 'sequelize';
class <%= pascalCase(name) %> extends Model {}
<%= pascalCase(name) %>.init({
<% fields.forEach(function(field) { %>
<%- field.name %>: {
type: DataTypes.<%= field.type.toUpperCase() %>,
allowNull: <%= field.allowNull || false %>
},
<% }) %>
}, {
tableName: '<%= snakeCase(name) %>',
});
export default <%= pascalCase(name) %>;
渲染时,我会注入一组 helper 函数,让模板保持简洁:snakeCase、camelCase、pascalCase、kebabCase 都是常用的转换。核心渲染代码:
typescript复制import ejs from 'ejs';
import * as naming from '../utils/naming';
export async function renderTemplate(
templatePath: string,
context: Record<string, any>
): Promise<string> {
return ejs.renderFile(templatePath, {
...context,
...naming,
}, { async: true });
}
这里有一处容易被忽略的坑:ejs 默认使用 <%= %> 输出转义后的内容,而代码模板里经常需要输出引号、尖括号这类字符。所以涉及代码行的地方,我统一用 <%- %> 输出不转义内容。如果你在模板里发现生成的代码多了 < 这类实体符号,就是转义没处理好。
3.5 安全写入:先预览、再备份、最后覆盖
文件写入是最危险的一步,误覆盖了手工改过的代码文件,代价不小。CodeMagicianT 做了三层保护:
第一层是 --dry-run。生成模式下加了这个参数,工具只渲染、只打印"将要创建哪些文件"的清单,不真正写入。这个参数应该成为团队默认习惯。
第二层是文件冲突检查。目标文件已存在时,默认会提示"覆盖 / 跳过 / 终止",绝不直接覆盖。
第三层是写入策略。写文件时先写到一个临时文件,确认写入成功后,再通过原子替换的方式覆盖目标文件。这样即使中途断电或者进程崩溃,也不会出现半截文件。
typescript复制import fs from 'fs-extra';
import path from 'path';
export async function safeWrite(filePath: string, content: string): Promise<void> {
await fs.ensureDir(path.dirname(filePath));
const tmpPath = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(tmpPath, content, 'utf8');
if (fs.existsSync(filePath)) {
const backupPath = `${filePath}.bak_${Date.now()}`;
await fs.copyFile(filePath, backupPath);
}
await fs.move(tmpPath, filePath, { overwrite: true });
}
这段代码里的备份逻辑是上线之后补的。当时一个同事反馈,生成器把手工调好的配置文件覆盖了,虽然有提示,但手一抖就按了回车。从那以后,每次覆盖前都自动生成一个带时间戳的备份文件,给用户留一条后悔路。
4. 实操演示:十分钟生成一个带 CRUD 的用户管理模块
4.1 准备模板目录
在真正使用之前,首先要把模板目录准备好。建议模板目录也纳入 Git 管理,和业务代码分开仓库,这样模板的变更记录独立、清晰。
一个典型的模板目录长这样:
text复制templates/
├── module/
│ ├── controller.ts.ejs
│ ├── service.ts.ejs
│ ├── model.ts.ejs
│ └── route.ts.ejs
└── project/
├── package.json.ejs
├── tsconfig.json.ejs
└── src/
└── index.ts.ejs
模板文件名里带 .ejs 后缀,扫描器会先按目录结构读出来,再逐文件渲染。非 .ejs 后缀的资源文件(比如静态图片、配置文件模板)会被原样复制过去。
4.2 编写一个简单的控制器模板
以一个 Express 风格的控制为例,模板的核心逻辑就是循环渲染字段,自动生成标准的增删改查方法:
ejs复制// controller.ts.ejs
import { Request, Response } from 'express';
import { <%= pascalCase(name) %>Service } from '../services/<%= kebabCase(name) %>.service';
export class <%= pascalCase(name) %>Controller {
async list(req: Request, res: Response) {
const items = await <%= pascalCase(name) %>Service.findAll();
res.json({ code: 0, data: items });
}
async create(req: Request, res: Response) {
const record = await <%= pascalCase(name) %>Service.create(req.body);
res.json({ code: 0, data: record });
}
}
这个模板里没有写死任何业务字段,只用 <%= pascalCase(name) %> 和 <%= kebabCase(name) %> 来保证命名一致。好处是:只要项目命名规范一致,以后无论生成多少模块,代码风格都一样。
4.3 执行生成命令
我以生成一个 user 模块为例,字段包括 name、email、age。
bash复制code-magician generate \
--template module \
--name user \
--fields "name:string,email:string,age:number" \
--output src/modules/user
如果不想在命令里写字段,直接执行这条命令后,交互式提问会引导你逐步填写信息:
text复制? 请选择模板 (Use arrow keys)
❯ module
project
? 请输入模块名称 (user)
? 请输入字段列表,例如 name:string,email:string,age:number
> name:string,email:string,age:number
? 目标输出目录 (src/modules/user)
参数收集完成后,工具会先打印一份"生成计划",列出将要创建的文件列表和每个文件的大小,等确认后再真正写入。这个预览环节强烈建议保留,能少踩很多坑。
4.4 检查生成结果
生成完成后,输出目录会是这样的结构:
text复制src/modules/user/
├── user.controller.ts
├── user.service.ts
├── user.model.ts
└── user.route.ts
打开 user.controller.ts 看看:
typescript复制import { Request, Response } from 'express';
import { UserService } from '../services/user.service';
export class UserController {
async list(req: Request, res: Response) {
const items = await UserService.findAll();
res.json({ code: 0, data: items });
}
async create(req: Request, res: Response) {
const record = await UserService.create(req.body);
res.json({ code: 0, data: record });
}
}
命中和预期完全一致:类名是 UserController 而不是 userController,目录名是 user 而不是 userProfile。这套模板的逻辑维护好之后,团队里其他人不需要懂模板语法,只需要会填参数,就能产出统一风格的代码。
4.5 如果生成的代码不符合预期?
我遇到的最常见情况是:模板是好的,但某个字段在数据库里的类型和代码里不一致。这时不要手动去改生成结果,而是应该调整模板或者增加字段配置。比如 age:number 默认映射成 DataTypes.NUMBER,但你可能想用 DataTypes.INTEGER,那就在字段配置里加一个类型映射:
json复制{
"fields": [
{ "name": "age", "type": "integer" }
]
}
模板里改成这样映射:
ejs复制DataTypes.<%= field.type === 'integer' ? 'INTEGER' : field.type.toUpperCase() %>
核心原则是:模板是唯一的代码风格来源,宁可花时间调整模板,也不要容忍"生成后手动改"的习惯。一旦开始手工补丁,模板就会慢慢失去约束力。
5. 常见问题与排查技巧实录
5.1 模板渲染报错,但看不出来是哪一行
ejs 模板渲染的报错信息有时候不够直观,只会告诉你第几行出错,但模板文件里嵌套了循环和条件时,行号和最终渲染结果对不上。
我的排查技巧是:在渲染函数里包一层 try-catch,捕获错误后打印完整的模板文件路径、模板缓存键名、以及出错的模板行内容。
typescript复制export async function renderTemplate(templatePath, context) {
try {
return await ejs.renderFile(templatePath, context, { async: true });
} catch (err) {
logger.error(`渲染失败: ${templatePath}`);
logger.error(`错误位置: ${err.message}`);
throw err;
}
}
经验是:模板出错的绝大多数原因是变量名拼写不一致,比如模板里写了 pascalCase(name),但上层 context 传入的是 moduleName,而不是 name。建议在注册 helper 时把所有命名转换函数铺平,不要在模板里写 context.user.name 这种深层访问,访问路径越浅,拼错概率越低。
5.2 Windows 上路径分隔符导致模板扫描不到
在 Windows 环境下,glob 扫描到的路径可能是反斜杠 \,但模板内引用子文件时用的是正斜杠 /,这就导致文件找不到。
解决办法是统一用正斜杠。扫描后立刻做一次路径规范化:
typescript复制import path from 'path';
export function normalizeSlashes(filePath: string): string {
return filePath.split(path.sep).join('/');
}
另外,一个重要教训是路径比较时不要依赖 ===,尽量用 path.relative 或者基于规范化后的字符判断。这个小问题在 Mac 上开发时完全不暴露,等团队里有人用 Windows 时就现出原形了。
5.3 如何快速在团队里推广使用
工具好写,推广难。我的几个实用经验:
- 第一个模板一定要"吃自己的狗粮"。先用 CodeMagicianT 生成一个真实项目,把过程中的所有不舒服都记下来,改到你自己愿意天天用为止。
- 提供一份"最小使用示例"文档,最好是复制粘贴就能跑通的那种。不要让使用者自己拼参数。
- 在 CI 里加一个检查:如果项目里出现了"模板可以生成但手写的、风格不一致"的代码,CI 就报警。刚开始可能有点烦,但是坚持一段时间,代码一致性会明显提升。
- 每次模板改动都要更新版本号,并且生成的文件头部可以加一行注释标明模板版本,排查问题的时候能迅速定位是生成器的锅,还是后来手工改出来的 bug。
5.4 模板本身也是有"测试"的
很多人写完模板就不管了,等到真正使用时才发现生成的代码根本跑不起来。我后面做了一个小改进:在测试目录里准备了一份"字段配置样例",每次改完模板就执行一次生成,然后对生成结果做 TypeScript 类型检查和 ESLint。
这看起来多花了一点时间,但换来的是"模板可用性"的确定性。生成工具如果自己生成的代码都带着语法错误,使用者很难有信心继续依赖它。
6. 我把这次的开发经验沉淀成了三条原则
做完 CodeMagicianT 并在团队里跑通之后,我反思了很多。工具本身不复杂,真正复杂的是"让别人愿意用它"这件事。
第一,代码生成器不是越智能越好,而是边界越清晰越好。它最适合处理的是确定性的、重复性的、可预期的代码结构;业务逻辑中那些灵活多变的部分,硬要自动化只会增加维护成本。
第二,模板和生成器同样是"代码",需要版本管理、测试和评审。你对待模板的态度,决定了使用它的团队最终的代码质量。
第三,自动化的目的是把人的精力释放到真正需要创造力的地方。写模板的过程就是把团队规范沉淀下来,这个过程本身很值得做,而且越早做,团队在代码一致性上的"欠账"越少。
如果你也在为重复造轮子的事头疼,不妨先控制住自己写一堆代码生成器的冲动,从最小的一个模板开始,找一小块确定性的代码,把它变成一条命令。我在打通第一个完整流程的时候,最直观的感受就是:原来那些让我烦躁的重复劳动,其实大多是因为我一直在用最笨的方式重复做同一件事。
