做了将近十年的后端开发,我发现自己有个不太体面的习惯:每接手一个新项目,第一件事就是把上一个项目的实体类、Mapper、Service、Controller翻出来,逐行改包名、改类名、改字段。直到连续复制了17个结构几乎一样的业务模块,凌晨两点盯着满屏似曾相识的代码,我终于下定决心做一个模板代码生成工具,把自己从这种机械劳动里彻底解放出来。
这篇文章不是工具的使用说明书,而是一个被重复代码逼疯了的人,从零开始搭模板生成工具的全过程记录。我会从模板引擎选型、数据模型设计、规则配置、实战案例到后期维护,把整个链路摊开讲。适合那些手头有大量重复代码模块、想做代码生成但不知道从哪下手的人,也适合已经在做工具但卡在自定义规则上的人。
1. 为什么我会被重复代码逼到自研模板生成工具
1.1 模板代码生成的边界:哪些代码确实值得生成
很多人一听到“代码生成”就想到那种一键生成整个管理后台的“神器”,但我从一开始就没打算做那么大的东西。我先列了个清单,盘点了项目里到底哪些代码让我复制到手酸:
- 分层架构里的实体类、DTO、VO,字段十来个,getter、setter占了大半
- 数据访问层接口和对应的XML映射,每个表的增删改查结构几乎一致
- Service层的标准接口和实现,除了掉不同的Mapper方法,骨架没有区别
- Controller的增删改查接口,参数校验、统一返回、分页查询,翻来覆去就那么几行
- 各种消息协议对象、配置映射类、状态枚举,字段多但逻辑少
这些代码的共同点是:结构固定、变化点集中、业务规则能通过参数表达。只要把“表名、字段列表、主键、类型映射、命名风格”这些参数抽出来,剩下就是套壳。
真正不推荐模板化的,是包含复杂业务判断、算法策略、状态流转、异常分支的代码。比如订单金额计算、库存扣减逻辑、审批流程决策,这些逻辑如果用模板写,模板里会塞满条件和嵌套,比维护手写代码还痛苦。那时候自定义规则再强,也只是给未来挖坑。
1.2 模板生成、脚手架和低代码平台是三回事
很多刚接触代码生成的人会把“模板生成工具”和“脚手架工具”混在一起,其实边界很清楚。脚手架工具解决的是项目从无到有的问题,比如Spring Initializr生成一个基础工程结构,你拿到的是整个项目的一次性初始快照;模板代码生成工具解决的是项目中大量重复模块从“有一个”到“每个业务都来一套”的问题,它是增量式的,可以反复对一个项目执行。
低代码平台又不一样,它通常是在运行时根据模型动态解释执行,生成的不是代码文件,而是可直接运行的功能。模板代码生成器的产物是真实代码,会进入版本管理、走代码评审、参与编译部署,它更贴近工程化流程。
我把这三者的关系总结成一句话:脚手架管项目出生,模板生成器管重复模块批量生产,低代码平台管业务运行时装配。工具的目标不是替代你的编程能力,而是把可穷举的部分自动化,把人的注意力留给不可穷举的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模板引擎选型和渲染原理:一块模板如何变成十层代码
2.1 渲染模型就三件事:模板、数据模型、输出位置
你不需要把模板引擎想得太玄乎,它的核心模型非常简单:一份带占位符的模板文件,一份由键值对组成的数据模型,一个输出目标。模板引擎负责把数据模型里某个字段的值,填充到模板对应的占位符上,然后把整块文本输出到文件或控制台。
我用FreeMarker做主力引擎,把它和一个最简单的String.replace做对比就明白了。String.replace只能做静态替换,模板里有循环、条件判断、时,写出来的代码会比Python手写字符串还要难维护。FreeMarker这类引擎支持<#list>、<#if>、<#macro>等指令,能在一个模板里表达“if字段类型是Long,则生成对应的包装类型声明”这种逻辑,而不需要在外层反复写if else拼字符串。
一个最简单的实体类模板,包含循环和条件,用FreeMarker大概是这样的:
code复制package ${basePackage}.entity;
import java.util.Date;
import java.math.BigDecimal;
public class ${entityName} {
<#list columns as column>
<#if column.javaType == 'Date'>
private java.util.Date ${column.fieldName};
<#elseif column.javaType == 'BigDecimal'>
private java.math.BigDecimal ${column.fieldName};
<#else>
private ${column.javaType} ${column.fieldName};
</#if>
</#list>
}
渲染时给引擎传入数据模型:basePackage = “com.example.order”,entityName = “Order”,columns = [{fieldName:“orderNo”, javaType:“String”}, ...],引擎就会递归处理列表和条件分支,最终生成一个Java实体类文件。
2.2 主流模板引擎横向对比:不是越强越好
我这些年用过的模板引擎不算少,各有各的脾气。下面这个表是我在实际项目里的直观感受,不是官方评测,仅供选型参考:
| 引擎 | 语言生态 | 学习曲线 | 调试友好度 | 典型场景 |
|---|---|---|---|---|
| FreeMarker | Java | 中等 | 一般 | 后端代码生成、HTML模板、邮件模板 |
| Velocity | Java | 中等 | 一般 | 老项目兼容、代码生成 |
| Thymeleaf | Java | 偏高 | 较好 | Web页面渲染,但做代码生成略重 |
| Go template | Go | 较平缓 | 一般 | 单文件工具、DevOps场景 |
| Pebble | Java | 平缓 | 一般 | 语法接近Jinja2,团队从Python转来适用 |
| 自研占位符替换 | 任意 | 最低 | 取决于实现 | 极简单、极少数固定格式的配置生成 |
如果只是生成几个固定格式的业务文件,用自研占位符替换完全够用,没必要把FreeMarker拖进来。但一旦生成的文件数量超过十类、模板之间还有公共片段、有大量循环和条件的需求,自研方案的代码量会失控——你本质上是在重写一个半成品模板引擎,而你在模板语法设计上踩过的坑,别人早就替你踩完了。
Go生态里我比较喜欢Go template,因为可以用text/template直接编译进单一二进制,不需要额外装JRE,适合做那种部署在公司内网的命令行生成器。Java项目里我还是首推FreeMarker,它的列表索引、内建函数、宏定义能力强,遇到?cap_first、?uncap_first这种命名转化操作,不需要自己写工具类。
2.3 模板语法的“心智负担”才是隐性成本
选引擎容易忽略的是团队的学习成本。模板引擎的语法不是标准编程语言,很多人在模板里写复杂逻辑,比如从循环变量里再做一次计算、把多个字符串拼接后再判空,结果就是模板越来越长,调试越来越难,最后没人敢改。
我给自己定了一条规矩:模板里只做控制流和最简单的取值,所有复杂的字符串处理、类型映射、计算逻辑,全部放到数据准备阶段完成。也就是说,能用Java代码做好的事情,绝不在模板里用FreeMarker函数硬写。这样做的直接收益是:模板清晰可读,新人半天就能上手维护;数据模型的字段设计和规则配置成为了核心资产,模板退化成纯粹的“载体”。
3. 自定义规则设计:真正让工具脱离“玩具”的元数据层
3.1 先有元数据,才有模板文件
很多代码生成工具从模板引擎里拿了渲染能力,却仍然只能生成“孤立的几段代码”,原因就是它们没有做好元数据建模。元数据是描述生成对象信息的数据,它决定了模板里能看到什么、规则里能判断什么。
我从数据库表结构入手,设计了最基础的表元数据模型:
json复制{
"tableName": "t_order",
"entityName": "Order",
"basePackage": "com.example.order",
"moduleName": "order",
"columns": [
{
"columnName": "id",
"fieldName": "id",
"javaType": "Long",
"dbType": "bigint",
"primaryKey": true,
"nullable": false
},
{
"columnName": "order_no",
"fieldName": "orderNo",
"javaType": "String",
"dbType": "varchar",
"primaryKey": false,
"nullable": false
}
]
}
这组数据看起来简单,但它是整个生成器的心脏。模板里需要的主键判断、类型映射、下划线转驼峰、字段可空性判断,全部从这一份元数据推导。设计元数据时我特别注意了“来源可溯”:表结构来自数据库连接,类型映射规则来自配置文件,字段归属和业务含义来自用户补充。只有数据来源清晰,生成结果才可解释。
3.2 规则配置的分层:全局规则、模板规则、字段规则
元数据解决了“生成什么”的问题,自定义规则解决的是“按什么风格生成”。我采用了三层配置结构,避免把所有规则堆在一个配置文件里变成无人敢碰的巨石。
全局规则管的是代码风格:默认作者名、日期格式、命名风格(下划线转驼峰、大驼峰还是小驼峰)、是否生成Lombok注解、是否生成Swagger注解。模板规则管的是文件落点和模板选择:比如entity.java.ftl这个模板对应输出到哪个路径,文件名怎么拼,是否支持覆盖。字段规则管的是细粒度控制:哪些字段不生成、哪些字段作为查询条件、哪些字段在更新时忽略,这里我用了一个简单但实用的excludeFields和queryFields配置。
yaml复制global:
author: "zhangsan"
dateFormat: "yyyy-MM-dd"
lombok: false
swagger: true
naming: "camelCase"
rules:
- template: "entity.java.ftl"
output: "src/main/java/{basePackage}/{moduleName}/entity/{entityName}.java"
override: false
- template: "mapper.xml.ftl"
output: "src/main/resources/mapper/{entityName}Mapper.xml"
override: true
field:
exclude: ["tenant_id", "deleted"]
query: ["order_no", "buyer_name"]
这里特别强调输出路径的{basePackage}、{moduleName}、{entityName}这些占位符。路径映射看起来是小设计,但它决定了生成器能不能被团队接受。如果生成的文件散落在错误目录,后面格式化、编译、提交全都乱了。我见过有人把路径写在模板引擎里,模板里拼了一堆<#if>判断输出目录,维护成本直线上升,所以我把路径规则独立出来,模板只关注文件内容。
3.3 规则文件要能被测试,否则就是维护灾难
自定义规则做得再花哨,如果不能快速验证,团队用几次就会抛弃。我要求所有规则都支持单独执行:给定一份元数据JSON和一份规则YAML,立刻就能在临时目录里渲染出目标文件,供人diff检查。
这里面有个很值钱的习惯:把数据模型和规则配置都作为可读文本存储,而不是硬编码在Java类里。因为规则本身也是产品的一部分,它该像代码一样进入版本库、走评审。我在实际项目中还加了一条校验逻辑,启动时扫描所有模板引用的变量,检查它们是否都能在数据模型里找到对应字段,找不到就报错。这个做法极大减少了“模板改了、数据模型没跟上”这类问题。
4. 20分钟复现一个CRUD生成器:核心代码与模板实战
4.1 实战目标:从数据库表到Controller一条龙
下面我用Java加FreeMarker做一个极简但完整的CRUD生成器,目标是从一张订单表生成实体类、Mapper接口、Mapper XML、Service接口和Controller。为了讲清楚,我把它拆成四个阶段:读取表结构、构建元数据、准备模板、执行渲染。
先建一个TableMetaFetcher工具类,通过JDBC的DatabaseMetaData获取表结构和主键信息:
java复制public class TableMetaFetcher {
public TableMeta fetch(String tableName, Connection conn) throws SQLException {
TableMeta meta = new TableMeta();
meta.setTableName(tableName);
meta.setEntityName(underlineToCamel(tableName, true));
DatabaseMetaData dbMeta = conn.getMetaData();
try (ResultSet rs = dbMeta.getColumns(null, null, tableName, "%")) {
while (rs.next()) {
ColumnMeta column = new ColumnMeta();
column.setColumnName(rs.getString("COLUMN_NAME"));
column.setDbType(rs.getString("TYPE_NAME"));
column.setNullable(rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls);
column.setJavaType(mapJavaType(column.getDbType()));
column.setFieldName(underlineToCamel(column.getColumnName(), false));
meta.getColumns().add(column);
}
}
// 继续读取主键...
return meta;
}
}
构建好元数据后,把元数据、规则配置、命名转化结果都塞进一个FreeMarker的DataModel。接下来创建Configuration并指定模板加载目录:
java复制Configuration cfg = new Configuration(Configuration.VERSION_2_3_33);
cfg.setDefaultEncoding("UTF-8");
cfg.setDirectoryForTemplateLoading(new File("templates"));
cfg.setObjectWrapper(new DefaultObjectWrapper(Configuration.VERSION_2_3_33));
Map<String, Object> data = buildDataModel(tableMeta);
Template template = cfg.getTemplate(rule.getTemplate());
template.process(data, writer);
这段代码的核心不在API调用,而在于把数据模型组织得足够规整。如果你在buildDataModel()里把实体名、字段名、主键、包名全部准备好,模板里的表达式会非常干净。模板保持简单,复杂度向上收敛,这就是前面说的原则。
4.2 三个典型模板文件:实体、Mapper、Controller
实体模板前面已经展示过,这里再放一个Mapper XML的片段,注意FreeMarker对XML的特殊字符处理:
code复制<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="${basePackage}.mapper.${entityName}Mapper">
<resultMap id="BaseResultMap" type="${basePackage}.entity.${entityName}">
<#list columns as column>
<result column="${column.columnName}" property="${column.fieldName}" />
</#list>
</resultMap>
<select id="selectById" parameterType="${primaryKey.javaType}" resultMap="BaseResultMap">
select * from ${tableName} where ${primaryKey.columnName} = #{${primaryKey.fieldName}, jdbcType=${primaryKey.dbType}}
</select>
</mapper>
Controller模板核心结构是标准REST接口,用上了<#if>判断是否生成分页查询:
code复制@RestController
@RequestMapping("/${moduleName}")
public class ${entityName}Controller {
@Autowired
private ${entityName}Service ${entityName?uncap_first}Service;
@PostMapping
public R<#if enablePage><PageResult<${entityName}><#else>Void</#if> create(@RequestBody ${entityName} entity) {
${entityName?uncap_first}Service.save(entity);
return R.ok();
}
}
看到?uncap_first这个用法了吗?它是FreeMarker内建函数,把首字母转小写。这种命名转化属于模板引擎擅长的操作,可以直接放在模板里,不需要在Java侧预先算好。但只是一次两次使用还好,如果每个字段都要做复杂命名转化,我建议还是提前在数据模型里备好一份。
4.3 效果验证:生成出来的代码必须能编译过
生成器写完之后,我要求自己在一个全新的空工程里跑完整套流程,生成结果必须能直接编译通过,不允许出现“生成完还要手补十处”的情况。具体执行时,我会把生成器输出到一个build/generated临时目录,然后用git diff --no-index和手写的参考代码对比,看命名、缩进、注解是否一致。
跑通生成只是第一步,生成后的自动格式化也很重要。Java项目可以用Spotless或google-java-format做统一格式化,XML可以用统一配置的解析器。格式化这一步不能省,否则模板里一个缩进错误会导致整个团队大量无意义的diff。我见过很多生成器被吐槽“代码风格没问题,但diff乱七八糟”,多半是在格式化和模板缩进上偷了懒。
最后一点,也是很多生成器容易忽略的:生成完一定要跑一遍项目原有的编译和测试。我处理过的最痛的问题就是,模板里写死了某个依赖的旧版本类型,生成出来的代码在编译期没问题,运行时因为依赖升级直接报NoSuchMethodError。所以我把“生成器验证”写进了CI流程里,每次改模板或规则,会自动生成一份样例工程并编译,确保不会再出现这类隐性破坏。
5. 生成器跑通之后的事情:覆盖策略、编码陷阱和调试链路
5.1 编码与乱码:模板文件的charset是个隐形地雷
这个东西看起来很小,但在Windows环境下写模板生成器的人十个里有八个遇到过。模板文件明明存成了UTF-8,FreeMarker读取时如果没指定setDefaultEncoding("UTF-8"),会按系统默认字符集读,中文注释直接变成乱码,生成的Java文件里到处都是“锟斤拷”。
更隐蔽的是,输出的Java文件写回磁盘时也要指定编码。我见过有人把模板读取编码设为UTF-8,但写入文件用的是FileWriter,默认字符集跑偏,生成的文件在IDE里显示正常,git提交到Linux后整个中文全乱。现在我的习惯是:所有模板读取、数据模型转换、文件写入统一显式指定UTF-8,宁可每个地方多写一行,不依赖任何默认值。
5.2 语法转义与特殊字符:FreeMarker和JSON、XML的爱恨情仇
模板代码生成里最让人难受的,就是目标语言和模板语言各自有一套特殊字符,它们在同一行里打架。
比如生成JSON配置文件时,模板里要保留${...}这种字符串,但FreeMarker也会把${}当成插值表达式。解决办法是FreeMarker的${r"${}"}原生字符串语法,它告诉模板引擎里层那个${}原样输出。类似地,生成XML或SQL时,模板里的<、>会被FreeMarker当成标签起始符,这时候要么用<#noparse>包住不需要解析的片段,要么用<#escape>统一转义。
我建议在写模板时,先做一轮“目标语言字符冲突”排查:把目标格式里可能和模板语法冲突的字符列出清单,逐个验证转义方式。这个清单最好沉淀成团队文档,否则每次换成新的目标语言都要重新踩一遍。
5.3 覆盖策略和幂等性:绝不能每次生成都覆盖手写代码
这是模板生成器在真实团队里生死攸关的一步。生成器刚上线时,大家都觉得新鲜,跑完一看代码,顺手改了注释、加了业务字段。第二次批量生成时如果直接覆盖,手写修改全部蒸发,第二天就会有人把工具拉黑。
我采用的方案是“生成区隔离+标记识别”:所有模板生成的文件顶部自动加一行// @generated注释,生成时如果目标文件已存在且包含@generated,有两种策略选择,一种是直接跳过,另一种是备份后覆盖。如果目标文件不包含@generated,说明有手写改动,绝不自动覆盖,只输出对比报告,交给人工处理。
更好的架构是把生成的代码和手写代码物理隔离:实体类、基础Mapper、基础Service由模板生成,手写扩展类写成OrderServiceImpl继承BaseOrderServiceImpl或通过接口默认方法做扩展。这样模板再怎么重新生成,手写的业务逻辑呆在隔离区不受影响。代价是代码结构会多一点间接层,但对多人协作的项目来说,这层间接换来了绝对安全。
5.4 调试链路:模板报错的时候怎么定位
模板引擎的报错信息通常比较抽象,经常只说“模板渲染错误”却不告诉你具体哪一行。我的调试三板斧:
第一板斧,数据模型先落地。在渲染前把DataModel整个序列化成JSON文件,模板报错时先检查JSON里的字段名、类型和模板引用是否一致。大量模板错误都是键名拼写不一致造成的,看到JSON一眼就能定位。
第二板斧,模板单独跑。每个模板写一个独立的main方法或单元测试,只喂一份最小数据模型,把渲染结果输出到临时目录。改一个模板只跑一个测试,不用每次把整个生成器跑一遍,反馈速度很快。
第三板斧,模板文件也做单元测试。我用JUnit对每个模板断言:生成结果里是否包含预期字段、是否包含指定类名、是否不包含被排除的字段。虽然写起来麻烦,但模板一旦多了,这层保护能省掉很多回归问题。模板本质上也是代码,不测试就是在裸奔。
5.5 模板和规则也要走代码评审
还有一条团队规矩值得强调:模板文件、规则YAML、元数据模型,全部进入Git仓库,有修改就走Pull Request评审。我见过不少团队把模板当作“写一次就不用看”的东西,等有人改了一行模板影响了二十个模块的生成结果,才意识到这个文件的分量。
模板评审重点看两件事:一是兼容性,凡是对已有生成结果有影响的修改,必须把生成前后diff贴出来;二是可读性,模板里如果出现三个以上的嵌套循环,评审基本不会通过,必须拆分或把部分逻辑移到数据准备阶段。
6. 从JVM到PLC、G代码和报告模板:模板生成能吃的场景比想象中广
6.1 不止CRUD:模板生成在工业控制里的价值
这段时间我注意到行业里有些热词很有意思,比如“AI PLC代码生成”、“G代码生成”。很多人以为模板代码生成只属于互联网后端,其实工业自动化领域更早就在用类似思路。PLC编程里有大量重复逻辑:IO点位映射、Modbus寄存器读写、报警文本定义、轴参数初始化,这些结构固定、数量庞大的代码块完全可以用模板生成。
我自己研究过一个简化的PLC模拟场景:把设备点位表导出成CSV,经过规则映射生成结构化文本格式的PLC程序段,输出文件再导入组态软件。整个过程和Java后端生成CRUD没有任何本质区别:点位表是元数据,PLC程序骨架是模板,规则表控制变量命名和数据类型映射。G代码也一样,一个加工件的钻孔列表配合刀具参数表,完全能一键生成整段钻孔循环指令。
这给我一个很强的信号:模板代码生成工具不是某个技术栈的专用品,而是一种通用工程能力。谁掌握了“定义元数据、编写模板、配置规则”这套方法论,跨行业复制起来非常快。
6.2 从代码到文档报告:模板生成的另一个方向
和工业场景相关的一个变体是什么?报告模板。我也看过类似“FastReport打印模板”、“后端批量填充Word模板”这类问题。它们表面上是报表需求,底层仍然是模板渲染:一份带占位符的Word/PDF模板,一组数据记录,一个批量输出器。
把代码生成和文档生成放在同一个工具框架里,很多团队是这么做的:元数据统一维护,生成器既可以输出Java文件,也可以输出接口文档Markdown、数据库变更SQL、测试用例Excel。当初疲于应付的“接口文档和代码不同步”问题,也会因为代码和文档共用一套元数据而自然缓解。这个扩展方向值得每个做代码生成的人留个心眼。
6.3 AI辅助时代,模板生成反而更重要了
市面上AI代码生成越来越热,有人觉得传统模板要过时了,我的看法正好相反。AI擅长从模糊需求中生成近似正确的代码,但它不稳定、不可审计、输出风格漂移;模板生成擅长的是精确、可重复、可审计的批量产出。两者互补性很明显。
我现在的做法是让AI做前置的元数据抽取和规则建议:比如给AI一张表结构说明,让它提出候选的字段命名映射、类型映射规则,人工确认后落到规则配置里,再由模板生成器完成最终代码产出。这样既利用了AI的理解能力,又把关在人工规则这条可控边界内。模板代码生成器依然负责稳定输出,只是“设计规则”这个动作从人脑搬到了人机协作。
另外,AI也可以辅助写模板。让AI先根据元数据和输出示例,产出一版模板初稿,再由有经验的工程师微调边界情况,远比从零手写模板快。但这个环节一定要人工过一遍渲染结果,AI对模板引擎转义规则的把握还没到能直接放行的程度。
回到我踩过的那些坑,说一句掏心窝的话:模板代码生成工具做到最后,真正的复杂度从来不在渲染引擎,而在元数据建模和规则设计。工具生成代码的速度很快,但把规则定义清楚、模板盯住边界条件、覆盖策略设计成防呆的,这才是细水长流的功夫。如果你正被重复代码困扰,别急着去写一个万能生成器,先找一个最小模块跑通整个链路,把元数据、模板、规则、输出、验证这五段完全打通,再慢慢扩展。这个最小闭环带来的收益,绝对是立竿见影的。
