1. 重复代码背后的维护成本:模板生成不是偷懒,是消灭“人工一致性”问题
如果你也跟我一样,在一个业务系统里维护过二三十个长得差不多的模块,那你对“模板代码生成工具”这几个字一定不陌生。前阵子我重构内部后台,光 Controller、Service、Mapper、XML 这套增删改查,手写了两遍就开始烦躁,写到第五遍时已经能闭着眼睛敲完。就是这种“熟练”最危险——人一旦在重复劳动里变熟练,就特别容易漏字段,更容易在第九个模块里把第八个模块的隐藏逻辑一起抄过来。
这个工具能解决的事情比很多人想象中宽。它不只是把一张数据库表变成一堆 Java 文件,它可以把“按既定规则重复产出代码”这件事,从手工操作变成可执行、可测试、可回滚的工程能力。适合谁用?后端团队做规范化接口模块、前端团队批量生成页面和路由、运维写配置片段、甚至嵌入式领域按寄存器表生成解析代码,都能在里面找到对应的玩法。我下面主要用 Java 后端这个最典型的场景展开讲,但思路完全能平移。
1.1 手写重复代码的痛点不在“手累”,而在“不一致”
很多人一提到代码生成,第一反应是“省时间”。但我的体感是,省时间只是副产品,核心价值是消除“人工一致性”问题。
举个例子,你写了十个模块,每个模块都有根据 ID 删除、根据 ID 查询、分页列表、新增、更新这五个接口。手写状态下,第一个模块用了统一返回体 R<T>,第五个模块可能因为当时图省事直接返回了实体对象,等到第十个模块又会写得和第一个不一样。这种差异不是因为开发水平不稳定,而是人的注意力没办法在低信息密度的重复代码里保持全程标准。
模板生成就不一样。接口签名、返回体的包裹方式、异常处理、日志埋点、参数校验注解,全部由模板文件统一控制。如果规范变化,比如公司要求所有分页接口多返回一个 totalPage 字段,你只需要改模板和对应的输出映射,所有模块一起更新。而手工去改几十个模块,改漏一个通常不是会不会的问题,而是哪次会的问题。
1.2 为什么现成工具和 AI 对话替代不了模板生成
说到代码生成,很多人会问:现在有 AI,为什么还要自己折腾一套工具?
我自己的判断是,不同的生成路线适用不同场景。现成的低代码平台适合在公司标准框架内快速搭后台,但一旦你的项目用了私有脚手架、特殊的数据权限逻辑或者历史遗留包名,低代码平台生成的代码往往很难落到你的工程目录里;云端“数据库表转 CRUD 代码”的网页服务,适合一次性 Demo,但它的命名规则、文件结构、注解风格和你团队规约大概率对不上。
AI 生成更适合“你不知道怎么写”的场景,比如查一个不熟悉的 SDK 调用方式,或者生成一小段边界清晰的业务逻辑。而模板生成解决的是“我已经知道怎么写、而且知道要写一模一样的五十遍”的场景。AI 生成的问题是结果不可完全预期,同一段提示词在不同上下文里可能给出不一样的结果,模板生成则是确定性的:同样的元数据输入,永远得到同样的输出。
这也是我最终自己维护一套模板代码生成工具的根本原因。模板本身不复杂,复杂的是怎么让你的生成规则可配置、可复用、可评审。它更像是一个“把规范固化成代码”的工程动作,而不是一个写代码的快捷方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模板代码生成工具的三大核心要素:元数据、模板语法、落盘约定
想自己写模板代码生成工具,先不用急着找框架。你要想清楚三件事:用什么作为生成依据,模板怎么写,生成的文件落在哪里。这三件事对应到工程上就是元数据、模板语法和落盘约定。
2.1 把“变化的东西”和“不变的东西”分开
模板生成最重要的思想不是“套字符串”,而是把变化和不变分离。不变的是结构,比如 Controller 里“查询->调用 Service->返回结果”这个顺序;变化的是数据,比如表名叫 product、字段有 product_name、主键叫 id。
所以第一件事是设计一个稳定的元数据模型。我用得比较多的是 JSON 作为输入配置,因为它不像 Java 类那样需要编译,也不像 XML 那样有大量冗余标签。一个模型的常见结构包括表名、模块名、字段列表、字段注释、字段类型、是否主键、是否逻辑删除、是否自动填充之类。
给一个直观的输入例子:
json复制{
"tableName": "product",
"moduleName": "product",
"entityName": "Product",
"apiPrefix": "/product",
"fields": [
{ "name": "id", "type": "bigint", "comment": "主键", "primaryKey": true },
{ "name": "product_name", "type": "varchar", "comment": "商品名称" },
{ "name": "category_id", "type": "bigint", "comment": "分类ID" },
{ "name": "price", "type": "decimal", "comment": "价格" },
{ "name": "status", "type": "int", "comment": "状态" },
{ "name": "created_at", "type": "datetime", "comment": "创建时间" },
{ "name": "updated_at", "type": "datetime", "comment": "更新时间" }
]
}
字段列表里那些“是否主键”“是否删除”“是否自动填充”不是浮于形式的标签,它们会直接影响模板里的条件分支。比如更新语句默认不应该更新主键,插入语句默认不需要插入创建时间,这些都是靠元数据里的属性来区分的。
2.2 占位符只是第一步,模板语法决定了生成器的表达能力
最早我做代码生成时犯过一个错误:直接用字符串替换,把 @TableName@ 换成表名,把 @fields@ 用循环拼接塞进文本。这种方式在只生成一个文件名的情况下够用,一旦模板里出现“如果字段是主键,要加 @TableId 注解,否则只加 @TableField”,字符串替换就直接失灵了。
模板引擎的价值就在这里。即便你只用最基础的插值、循环、判断能力,也能写出比纯拼接清晰得多的模板。我常用 Handlebars,因为它语法极简,服务端和前端团队都容易上手。一个实体类的模板核心逻辑大概长这样:
hbs复制package {{basePackage}}.entity;
import lombok.Data;
@Data
public class {{entityName}} {
{{#each fields}}
/** {{comment}} */
private {{mapType type}} {{toCamel name}};
{{/each}}
}
这里的 {{each}} 就是循环,{{mapType type}} 是一个自定义辅助函数,作用是把数据库类型映射成 Java 类型。模板引擎帮你处理了缩进、循环体、上下文取值,你就不用自己拼字符串了。
如果你要支持的规则再复杂一点,比如更新模板里不生成主键字段,就需要用到条件判断。类似“字段是主键就不参与更新”这种逻辑,用模板引擎表达会非常直观。越早把这种能力纳入模板,后面维护越省力。
2.3 落盘约定比渲染本身更容易被忽略
很多第一次做生成器的人会把大量时间花在“怎么把模板渲染出来”,然后发现自己根本没想清楚“渲染出来的代码放哪”。
如果生成的代码不知道落在哪个 Maven 模块里,不知道 Controller、Service、Mapper 分别对应哪个包路径,那生成器就只能算一个演示 Demo,不能算工程工具。落盘不能靠“生成完以后手动拖文件”,应该有一套可配置的输出路径映射。
实践中我会把模板分成两类:一类是新增文件,例如实体类、Service 接口、Controller 类,生成后直接落到 src/main/java/对应包路径/ 下;另一类是增量文件,例如需要在某个已有的统一配置里追加一段路由规则,这种不适合直接覆盖,需要预留手写区。后面讲二次修改时我会专门展开。
3. 从零实现一个可自定义规则的模板代码生成器
说再多原理,不如直接跑通一次生成。我这里用一个 Node.js 版本的核心实现来演示,模板引擎用 Handlebars。选它不是因为它比别的引擎更高端,而是因为 Node 环境在多数团队都能快速跑起来,不需要额外的语言运行时,而且模板引擎生态很成熟。
3.1 整套工具用到的目录结构
我把工具本身和业务项目分开,单独放一个仓库:
text复制codegen/
├── config.json
├── generator.js
├── input/
│ └── product.json
├── templates/
│ ├── entity.hbs
│ ├── mapper.java.hbs
│ ├── service.java.hbs
│ └── controller.java.hbs
└── output/
input 目录放每个模块对应的模型 JSON;templates 放代码模板;output 放生成产物。有人喜欢把模板直接塞进业务工程,我不太推荐,因为模板本身属于“生成工具资产”,它和业务代码的生命周期不一样,分开管理更清爽。
config.json 里一般放类型映射、默认包名、输出根目录:
json复制{
"basePackage": "com.example.demo",
"outputRoot": "output",
"typeMap": {
"varchar": "String",
"text": "String",
"bigint": "Long",
"int": "Integer",
"decimal": "BigDecimal",
"datetime": "LocalDateTime",
"date": "LocalDate"
},
"defaultType": "String"
}
为什么要把类型映射单独抽出来?因为如果你把 varchar -> String 这种规则写死在代码里,每换一个语言或者换一个 ORM 框架,你都不得不改生成器源代码。放进配置后,生成器代码通常不用变,变的只是配置文件。
3.2 生成器的核心实现:读取、增强、渲染、落盘
生成器的主流程可以拆成四步:读取模型 JSON、注册模板辅助函数、遍历模板渲染、按文件名规则写入。
js复制const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');
const rootDir = __dirname;
const config = JSON.parse(
fs.readFileSync(path.join(rootDir, 'config.json'), 'utf-8')
);
const inputName = process.argv[2] || 'product';
const model = JSON.parse(
fs.readFileSync(path.join(rootDir, 'input', `${inputName}.json`), 'utf-8')
);
function toPascal(value) {
if (!value) return '';
return value
.split(/[_\-]/)
.filter(Boolean)
.map((item) => item.charAt(0).toUpperCase() + item.slice(1))
.join('');
}
function toCamel(value) {
const pascal = toPascal(value);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
function mapType(type) {
return config.typeMap[type] || config.defaultType || 'String';
}
Handlebars.registerHelper('toPascal', toPascal);
Handlebars.registerHelper('toCamel', toCamel);
Handlebars.registerHelper('mapType', mapType);
const data = Object.assign({}, model, {
basePackage: config.basePackage,
entityName: model.entityName || toPascal(model.tableName),
lowerEntityName: model.lowerEntityName || toCamel(model.tableName)
});
const categories = {
entity: (d) => `${d.entityName}.java`,
mapper: (d) => `${d.entityName}Mapper.java`,
service: (d) => `${d.entityName}Service.java`,
controller: (d) => `${d.entityName}Controller.java`
};
这里最需要注意的是一个容易踩的坑:表名叫 product_category_rel 时,类名不能简单地把所有下划线去掉变成 ProductCategoryRel,你可以保持这个规则,但要去掉业务库里常见的前缀。前缀规则、复数规则、命名缩写规则,都应该放到一个辅助函数里集中管理,不要散落在多个模板中。
接下来是我实际运行时非常关键的循环:
js复制const templateDir = path.join(rootDir, 'templates');
const files = fs
.readdirSync(templateDir)
.filter((name) => name.endsWith('.hbs'));
for (const file of files) {
const category = file.replace(/\.hbs$/, '');
const fileName = categories[category];
if (!fileName) continue;
const content = fs.readFileSync(path.join(templateDir, file), 'utf-8');
const template = Handlebars.compile(content);
const rendered = template(data);
const packagePath = config.basePackage.replace(/\./g, '/');
const outputDir = path.join(
rootDir,
config.outputRoot,
category,
packagePath
);
fs.mkdirSync(outputDir, { recursive: true });
const outputFile = path.join(outputDir, fileName(data));
fs.writeFileSync(outputFile, rendered, 'utf-8');
console.log(`generated: ${outputFile}`);
}
写入之前必须用 mkdirSync(dir, { recursive: true }),否则目录不存在就直接报错。这个细节看着小,但第一次跑十有八九会忘。
如果你觉得模板不一定只在 Java 里用,categories 这个映射就改成前端模板的映射表,比如 page.vue.hbs 生成 产品列表.vue,service.ts.hbs 生成 product.ts。所以工具内核本身不需要绑定某一种语言,绑定语言的是模板和文件映射规则,这正是自定义规则的意义所在。
3.3 模板辅助函数:engine 不懂业务规则,helper 懂
Handlebars 这类模板引擎本身只懂“插值、循环、判断”,它不知道什么是数据库字段的主键,也不知道什么是自动填充时间。所以你必须通过 helper 把业务语言注入进去。
举个例子,实体类模板里要根据字段类型引用对应的 Java 包。如果直接在模板里放一堆 {{#if}},模板会变得又臭又长。更干净的做法是在 JS 里注册一个 helper:
js复制function javaImport(type) {
if (type === 'BigDecimal') return 'import java.math.BigDecimal;\n';
if (type === 'LocalDateTime') return 'import java.time.LocalDateTime;\n';
return '';
}
然后在模板里用 {{{javaImport (mapType type)}}} 输出。注意我用了三个大括号,这是为了避免 Handlebars 把换行符转义掉。代码生成场景下,动态内容几乎不需要 HTML 转义,所以用到这种“原始输出”的情况非常多。
自定义规则的精髓就在这里:规则越往上抽象,模板越干净;规则越往下堆,模板越像一碗粥。我见过有人把类型映射和字段格式都写在同一个模板里,最后模板 200 行,评审的人根本不知道改哪里。正确的做法是把“判断逻辑”尽量放到 helper 或 JS 的数据预处理阶段,模板只保留最直白的生成结构。
4. 自定义规则的设计经验:一套生成器如何适配多种项目规范
工具跑通以后,真正让人头疼的事情才开始:你换了另一个项目,包名不同、数据库字段风格不同、要生成的代码类型也不同。这时“可自定义规则”就变得非常关键。
4.1 类型映射表是第一级规则,不要直接改模板
我曾经在一个项目里发现实体类字段的类型应该是 Long,但数据库字段是 int unsigned,结果生成成了 Integer。后来查问题,发现是类型映射表里漏了 int unsigned 这个场景,它被 defaultType 兜底成了 String,这个 bug 不仔细看很难发现,因为代码能编译,只是运行时可能出现类型转换异常。
所以我在第一版工具稳定后,专门加了一个规则校验:输入模型里的每个字段类型,必须在映射表里找得到;如果找不到,不是用默认类型悄悄代替,而是直接报错,同时输出“未识别类型”清单。宁可让流程中断,也不要让一个隐藏的类型错误溜进几百个生成文件里。
这套校验逻辑也适用于前端生成。比如你的后端接口返回 Long,前端 TypeScript 里还按 number 处理,在数据超过 JavaScript 安全整数范围时会丢精度。类型映射表就是用来预防这种跨语言类型错配的。
4.2 命名风格转换永远没有银弹,但要有可覆盖规则
不同表的命名习惯差异很大。有的表叫 pd_product,需要把 pd_ 去掉再转类名;有的表叫 sys_user_role_rel,转成实体名时可能想保留 SysUserRoleRel,但转成接口路径时又希望去掉 rel 后缀。
我在实际代码里给输入模型加了几个可选字段:prefixToRemove、classSuffix、apiNameOverride。如果没有传,就使用全局默认规则;传了就覆盖默认值。这样一个生成器可以同时服务多个业务线,而不是每个业务线都 fork 一份生成器。
这里有一个操作细节:命名转换最好在数据预处理阶段做掉,把转换后的 entityName、lowerEntityName、apiPrefix 都显式写入 data 对象。别在模板里反复调用 helper 做转换。原因是模板如果复杂了,调试起来会特别痛苦,而如果转换后的值都在 data 里,你可以直接在生成前输出一份 JSON 来检查中间结果。
4.3 通过模型标记位控制生成范围,而不是搞一堆模板开关
刚开始我把“是否生成 Controller”“是否生成 Service”做成了同一个模板里的条件分支,每当业务方说“这个模块不要 Controller,只要 Service”,我就要往模板里加条件判断。后来模板越来越乱,我才意识到方向错了。
正确方式是把它作为请求级配置。模型 JSON 里加一个数组:
json复制{
"tableName": "product",
"only": ["entity", "mapper", "mapperXml"]
}
生成器遍历模型时,只读取 only 列出的模板类别。这就相当于“这份数据需要生成什么”是跟着数据走的,而不是跟着模板走的。随着模板数量变多,这个设计会比任何复杂的模板分支都清晰。
我在工具里还维护了一个“字段标记规范”,像 primaryKey、logicDelete、versionLock、autoFillCreate、autoFillUpdate 都是预设标记。模板逻辑只跟标记打交道,不直接判断字段名是不是叫 created_at。这样以后有人把字段改名为 gmt_create,你只需要在模型里重新标记一下,模板一行都不用动。
5. 真实落地过程:从一个业务模块扩散到整个后台体系
讲完了代码和规则,我来说说一次真实的落地过程。我们当时要对一个内部后台进行第二次重构,涉及商品、分类、促销、优惠券等三十多张业务表。这些表的接口模式高度统一:登录鉴权完,进入一个 Controller,然后就是标准的 CRUD 加分页。
5.1 第一批试点只选了结构最简单的一张表
我一开始没有把所有表一次性塞进生成器,而是挑了 product_tag 这张最简单的标签表。原因很现实:简单表跑通了,你才能确认模板和输出目录设计本身没问题;如果一上来就处理多对多关系表、带历史快照的表,你会分不清是数据模型的问题还是模板引擎的问题。
首轮我只生成了实体类、Mapper 接口和 MyBatis XML。Service 和 Controller 我选择手工写,因为当时业务里有一部分校验逻辑还没想清楚统一规则。让生成器先覆盖“完全没有争议”的几层,跑出来的代码能被团队其他人一眼看懂,这是获得信任的第一步。
5.2 框架稳定后再让模板覆盖更多类别
当实体、Mapper 这些基础层跑顺后,我继续加了 service.interface、service.impl、controller 三类模板。注意,Service 接口是必须的,但实现类里的“真正业务”究竟放哪里,必须在一开始就定义清楚。
我用了折中方案:Service 实现类里生成标准模板代码,比如参数校验后的空判断、主键存在性检查等,但对于“订单金额超过一千需要走风控审核”这类规则,模板只生成一个方法调用点,方法体留空,由开发人员在手写区补充。这样既有生成效率,又不会把人类的业务判断能力全部抹掉。
扩散到几十张表后,我们的统计结果是:以前新增一张普通业务表,从建表、写实体到能调通接口,差不多需要半天到一天;用模板生成器后,建表脚本完成、模型 JSON 写完、执行一次 node generator.js order,生成一分钟不到,剩下半天时间基本都花在真正有业务含义的地方。更重要的是,每个模块的结构差异肉眼可见地被拉平了,代码评审时不会再出现“这个类的风格和那个类完全不像同一组人写的”这种尴尬。
5.3 生成器不适合处理高度不确定的业务编排
有一点我必须说得非常直白:模板代码生成工具解决的是“有确定规则的重复”,不是“有味道的业务逻辑”。我曾经试着把“根据订单状态触发不同审批流”这种状态机逻辑也做成模板,结果模板里全是嵌套判断,数据模型里塞满了乱七八糟的状态枚举,最后维护成本比手写还高。
这类高度依赖上下文、状态流转顺序、外部系统回调的逻辑,更适合用普通代码加设计模式去写,不适合为了“统一”“标准化”硬套模板。判断标准可以简单一点:如果一段逻辑你能用“遍历这张表所有字段,按固定规则生成语句”描述清楚,它适合模板;如果你需要画状态图才能讲明白,别硬塞给模板。
6. 重新生成与手写修改之间的平衡:如何不让工具变成“一次性玩具”
模板生成器最容易被弃用的时间点,不是第一次生成后,而是第一次需要“改了生成代码再让工具重新生成”的时候。
6.1 生成文件与手工修改区的边界必须显式化
刚开始我们生成的 ServiceImpl 直接放进业务工程,开发人员为了接业务,会在生成的方法体里写不少代码。后来我调整了模板,希望让新代码增加一个统一日志打印,一重新生成,之前手工加的业务代码全部被覆盖了。这个事故让团队差点把整个生成器拉黑。
之后我引入了显式的手工修改区标记。模板生成 ServiceImpl 时,方法体里默认放一组注释标记:
java复制// ===== BEGIN MANUAL =====
// 这里的方法体由开发人员手工维护,重新生成时不会覆盖
// ===== END MANUAL =====
生成器在覆盖旧文件之前,会先读取旧文件里两个标记之间的内容,渲染完新内容后再把这段内容原样插回去。这个过程本质上是在让渡一部分“全量控制权”,但它是必要的。生成器负责结构骨架,人负责填充真正的业务,两个世界用显式标记隔开,谁也不会误伤谁。
6.2 模板升级后的回归要用 git diff,而不是直接覆盖
团队人数变多以后,我通常会把“模板改动”和“业务代码改动”分开提交。模板升级之后,生成产物和上次生成的产物之间一定有一堆 diff,这些 diff 里有些是预期的,有些可能是模板引入的问题。每次升级完模板,我都会在测试分支上重新生成一个模块,用 git diff 肉眼检查这个模块的变化,再决定是否批量应用所有表。
这里有一个非常实用的建议:不要一开始就把生成器直接接到所有模块上。先在测试分支里选两个模块做全量重新生成,检查 diff 是否符合预期。比如“增加了统一日志打印”,那 diff 应该每个方法只多一条日志语句,如果有的方法多了一些奇怪的换行和缩进,那通常不是模板逻辑问题,而是模板文件本身的空白字符没有控制好。
6.3 生成器代码本身也要像业务代码一样评审和测试
我见过很多团队的生成器是某个人私下写的脚本,别人根本不敢碰。为了让生成器能长期存活,它必须像业务代码一样可维护。我做的几件小事供你参考:给模板文件加版本注释;给模型 JSON 写 schema;给生成结果做一次“冒烟编译”而不是只看文本长什么样。
生成器虽然叫“模板”,但本质上它是你团队规范的可执行文件。模板里写错了 {{mapType type}},影响的不是一行代码,而是所有模块的实体类属性。越是“一次生成几十个文件”的工具,越要在犯错之前拦住你,所以我在 generator.js 里加了产物数量检查和必填字段校验。这些都是普通编码过程中不会特意说明,但实际一跑就能提升幸福感的细节。
7. 模板语法细节与坑位记录:缩进、空白字符、跨平台路径
到了这个阶段,代码生成器已经能正常工作了,但真正决定它好不好用的,往往是那些不起眼的语法和文件细节。我把自己踩过的比较典型的坑放在一起说。
7.1 模板里的空白字符会污染生成结果的阅读体验
Handlebars 的 {{#each}} 和 {{/each}} 之间如果存在多余空行,生成出来的文件也会跟着有多余空行。问题在于模板里肉眼看起来正常的空行,多行代码拼接后可能变得特别稀疏。解决方法是使用 {{~ 和 ~}} 这种去除空白语法,让模板按自己的意图控制换行。
我可以给你一个判断标准:生成后的文件应该像人手工写的代码,而不是模板引擎的产物。如果每个字段之间被强制插入了两个空行,那阅读体验说明模板的空白控制没有做好。这个细节会在评审单上被反复提,所以第一次写模板时就要留意。
7.2 路径分隔符和文件编码在跨平台场景下会翻车
Windows 下用 path.join 和 Linux 下用 / 生成的路径并不总是一致,在 Node 里用 path.join 能规避大多数问题。另一个更隐蔽的问题是文件编码。很多模板编辑器默认保存为 UTF-8 with BOM,生成出的 Java 文件第一行就带了一个不可见字符,编译时偶尔会报“非法字符”。
我后来在生成器里加了强制转换:模板读取和文件写入都显式指定 utf-8,同时跑一遍 BOM 清理脚本。如果你所在团队的开发环境不统一,这一步一定要提前处理,否则别人生成的代码在你电脑上编译不过,问题会非常难排查。
7.3 模型字段的顺序不是随意的,它是生成结果的“潜意识排序”
有些人生成实体类时,字段顺序完全按照数据库表结构来,这本没错,但如果你在模型里把 id、created_at、updated_at 混在中间,生成的实体类可读性就会下降。我会在生成器里加一层固定的排序规则:主键排第一,逻辑删除字段排最后,创建时间和更新时间排在靠前或靠后,中间是真正的业务字段。
字段顺序看起来不涉及逻辑正确性,但它决定了一个团队在阅读代码时的共同习惯。当所有人都习惯“实体类第一行是主键,最后一行是版本号”,后续做代码评审、写映射、甚至排查字段遗漏都会顺畅很多。模板生成器不应该为了“完全还原数据库”而放弃代码可读性,它更应该成为团队编码规范的执行器。
7.4 生成多个文件后,要自动做一次“空模板”和“未定义值”检查
一个真实踩坑经历是:输入模型少写了一个字段,但模板里引用了 {{unknownField}},Handlebars 默认会渲染成空字符串而不是报错。结果是生成的代码没有语法错误,只是某个初始化逻辑静默消失了,这个问题直到运行期才暴露。
后来我开启了 Handlebars 的严格模式,同时给模板里涉及的必填项加了一个 helper,只要 key 不存在就直接抛异常。严谨的生成器宁可中断执行,也不要给用户一份看起来能用、实际缺字段的代码。生成时崩溃比线上运行时崩溃好处理一万倍,这不是夸张。
我把这些细节记下来之后,工具才真正从“自用脚本”变成了“团队可共享工具”。每次有人问模板代码生成器和普通复制粘贴有什么区别时,我的回答都很简单:复制粘贴是你在替代码库做记忆,而模板生成是让规则自己长在代码库上。你把规则的维护做扎实了,才能真正腾出手去解决那些没有模板的复杂问题。
