
这段时间后台收到不少消息都在问“模板代码生成工具”到底是怎么做的。有刚入行的同学想给自己的项目搭一套代码生成器也有团队Leader在调研怎么统一项目规范。这话题本身不小牵扯到模板引擎、代码结构设计、工程化落地一堆东西。我前前后后在不同项目里折腾过几套方案踩了不少坑今天就把整个思路和实践经验整理出来希望能给正在做选型或者准备自己动手的朋友一些参考。1. 模板代码生成到底在解决什么问题先说一个最直接的感受绝大多数业务系统的后端代码七八成都是高度重复的。一个标准的增删改查模块从Controller到Service再到Mapper结构几乎一模一样差异无非是表名、字段名、类型、校验规则这些。以前team里新项目启动前两周基本都在复制粘贴老代码然后全局替换包名和类名光这种机械劳动我至少干过几十次。一旦源项目的代码风格有调整或者框架版本升级所有复制出来的模块都得重新手改一遍非常消耗耐心。1.1 那些年手写CRUD的痛我见过一种非常典型的工作流新人入职第三天Leader丢给他一个老项目说“照着这个写”。于是新人打开旧代码按下CtrlC再打开新模块按下CtrlV然后把所有User改成Order、把userId改成orderId遇到字段多一点的表光改字段就要花半小时。改完之后还要检查import有没有漏、XML里的resultMap有没有对齐全、跟数据库实际的字段类型是不是一致。运气好一遍过运气不好就是编译报错、运行报错、字段对不上来来回回折腾大半天。这种模式的问题不在于“复制粘贴”这个行为本身而在于它完全不可控。老代码里的历史包袱会被原封不动地复制到新项目里比如一些遗留的废弃接口、多余的注释、过时的依赖。而且一旦复制来源有问题所有下游模块全都跟着错。后来我开始意识到真正该做的不是让每个人手动复制而是把“复制代码”这件事本身自动化。1.2 模板化的本质把变化和不变拆开代码生成工具解决的核心问题用一句话概括就是把业务代码里“不变的部分”固化成模板把“变化的部分”提炼成参数。同样的Controller写法、同样的Service分层、同样的分页封装这些是“不变”的做成模板之后再也不用手写第二遍而表名、字段列表、主键类型这些是“变化”的抽出来作为生成时的输入参数。这个思路放到实际的工程里还有一个更深层的好处它是天然的规范强制工具。如果公司代码规范要求Controller统一返回Result对象、不允许直接返回实体类那只要模板里写死了这套逻辑所有生成的代码天然符合规范。反过来说如果团队里有人手写代码风格绝对是五花八门的有的返回Result、有的直接抛异常、有的在Controller里写业务逻辑。模板生成从源头上消灭了这种差异。2. 模板代码生成工具的核心机制要理解模板代码生成工具得先理解它背后的“模板引擎”到底是怎么工作的。我自己最早接触这个概念是大学时写PHP用的Smarty后来做Java用了Velocity、FreeMarker再往后做前端接触了EJS、Handlebars理解的核心机制其实是通用的。2.1 模板引擎的基本功占位符、循环与条件一个模板引擎最基础的能力是变量替换。你在模板文件里写一段类似${className}这样的占位符引擎在渲染的时候会把对应的变量值塞进去。这是所有模板引擎共通的模型模板文本 数据模型 最终输出。真正让模板区分出高低的是循环和条件判断。比如要生成一个包含多个字段的实体类模板里不能写死一个字段而是要遍历传入的字段列表每遍历一次就输出一段属性定义代码。这种场景下模板引擎至少得支持#list这类循环语法以及#if这类的条件语法用来处理诸如“如果字段是主键则加上Id注解”这样的逻辑。下面是一个Java项目里典型的实体类模板片段用FreeMarker语法写的 package ${packageName}.entity; import lombok.Data; import javax.persistence.Id; import javax.persistence.Table; import java.math.BigDecimal; import java.util.Date; Data Table(name ${tableName}) public class ${className} { #list fields as field /** * ${field.comment} */ #if field.primaryKey Id /#if private ${field.javaType} ${field.fieldName}; /#list}这段模板乍看复杂拆分来看就很容易理解${packageName}和${className}是变量替换#list fields as field是循环遍历字段列表#if field.primaryKey是主键判断。整个生成过程就像一个“填空游戏”把数据模型里的字段列表、包名、类型信息填进模板的各个缺口最后输出一个完整的Java文件。2.2 模板宏与片段复用让模板本身也保持整洁很多人写模板写的多了之后会发现一个问题模板文件越写越长各种公共片段在不同模板里重复出现比如实体类里的分页字段、Controller里的统一返回封装。这时候就需要用到模板引擎的“宏”或者“include”机制。FreeMarker里可以用#macro定义一个宏比如定义一个输出分页字段的宏然后在实体类模板、DTO模板、VO模板里分别引用。这样以后分页字段有调整只需要改宏定义那一处所有引用它的模板在下次生成时自动使用新逻辑。#macro pageFields /** 当前页 */ private Integer pageNum; /** 每页条数 */ private Integer pageSize; /#macro这个思路在工作量上可能看不出多大优势但维护体验是完全不同的。模板和普通代码一样也需要重构、需要消除重复。如果一份模板里到处是重复片段那这份模板本身就变成了新的技术债。3. 怎么选型文本模板引擎、脚手架、低代码平台还是IDE片段市面上号称“代码生成”的工具很多但底层思路完全不同。我按自己接触过的范围把它们归成四类文本模板引擎、交互式脚手架、低代码平台、IDE代码片段。很多人在选型的时候容易混淆这四者的边界我一个个说清楚。3.1 四种主流路线的对比类型典型代表使用场景上手难度灵活度文本模板引擎FreeMarker、Velocity、EJS批量生成项目中的固定代码如实体、Mapper、Service中高交互式脚手架Yeoman、create-vue、若依初始化整个项目骨架交互式引导配置低中低代码平台各种在线后台生成器在线配置数据表直接生成可运行的后台低低IDE代码片段VS Code Snippets、IDEA Live Templates单文件、小块代码的快速插入极低低选型的核心判断标准就一条你生成的是“整个项目”还是“项目里的某些模块”如果想一键拉起一个包含登录、权限、菜单管理的新项目用脚手架类工具最合适如果是日常开发中需要从数据表生成对应的CRUD代码文本模板引擎更精准如果是那些不经常变化的固定代码片段IDE的Snippets已经足够。3.2 文本模板引擎的选型要点如果确定要走文本模板引擎这条路线接下来要做的就是选一个具体的引擎。拿Java生态来说Velocity和FreeMarker是最常见的两个选择。FreeMarker的功能更丰富语法更严格报错信息也更友好Velocity上手更快但模板写复杂之后容易嵌套得乱七八糟。我自己最终长期使用的是FreeMarker原因不是它性能有多强而是它的错误提示相对友好。模板报错是最令人崩溃的场景之一一堆没有行号的堆栈信息排查起来非常痛苦。FreeMarker的报错会标明模板文件的具体行号在很多情况下能直接定位到出错的语法。dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency这里还要注意一个细节Freemarker的版本差异比较大2.3.x系列里不同小版本的语法兼容性会有细微差异最好不要随便升级。4. 实操从零搭一个业务代码生成器理论说了一堆现在重点来了怎么从零开始搭一个能用的代码生成器。我没有用那些现成的开源工具而是选择基于FreeMarker从零手写了一套轻量生成器因为最核心的诉求是要完全掌控生成逻辑。下面按步骤拆解整个过程。4.1 设计数据模型与生成骨架在动手写模板之前第一件事是设计数据模型。数据模型就是模板渲染时传入的参数集合它决定了模板里能引用哪些变量。以我们常见的单表CRUD生成为例数据模型至少需要包含以下字段public class TableInfo { // 表名如 t_user private String tableName; // 类名如 User private String className; // 包名前缀如 com.example.project private String packageName; // 字段列表 private ListFieldInfo fields; } public class FieldInfo { // 字段名如 user_name private String fieldName; // 属性名如 userName private String propertyName; // Java类型如 String、Integer、Date private String javaType; // 数据库类型如 varchar、int、datetime private String dbType; // 字段注释 private String comment; // 是否主键 private boolean primaryKey; }有了这两个数据结构生成逻辑就变得非常单纯读取数据表元数据可以通过JDBC的DatabaseMetaData或者直接连数据库查information_schema把元数据转换成上面的对象再塞给FreeMarker渲染输出文件。4.2 编写模板的细节与占位符约定数据模型确定之后就该写模板了。写模板和写代码的思维方式不太一样它更像“反向思维”你不是在写最终代码而是在写“能生成最终代码的一种描述”。我强烈建议在写模板之前先把一份标准的、手写的目标代码准备好然后对照着这份目标代码去写模板。具体做法是先手写一份完整的、规范的UserController.java然后标出哪些是固定的、哪些是变化的把变化的部分替换成模板占位符固定部分原样保留。以Service层模板为例一份最简版本的模板长这样package ${packageName}.service; import ${packageName}.entity.${className}; import com.baomidou.mybatisplus.extension.service.IService; public interface ${className}Service extends IService${className} { }这个过程中有一个特别容易犯的错误占位符命名随心所欲。有人用${name}有人用${ClassName}还有人用${class_name}到后面模板多了变量名互相矛盾维护起来非常难受。我的做法是统一约定一套命名规则packageName、className、tableName、fields、fieldName、propertyName、javaType、dbType、comment、primaryKey。所有模板统一使用这套命名不允许出现自定义变体。4.3 调试与验证生成结果的正确性检查模板写完之后最关键的环节是验证生成结果是否正确。这一步我建议自动化不要靠肉眼检查。我当时写了一个JUnit测试用一张模拟的表结构作为输入执行一次完整生成然后自动编译生成出来的代码。编译通过说明语法没问题但还不代表逻辑正确所以测试里还会断言生成文件的关键片段比如必须包含Data注解、必须包含serialVersionUID等关键点。Test public void testGenerateEntity() { TableInfo tableInfo mockTableInfo(); GenerationResult result generator.generate(tableInfo, entity); // 关键断言 assertTrue(result.getContent().contains(Data)); assertTrue(result.getContent().contains(private Long id;)); assertTrue(result.getContent().contains(Table(name \t_user\))); }这个自动化验证看起来很笨但对模板的回归保护价值极大。模板这个东西有个特点改起来一时爽错了火葬场。改动一个公共宏的缩进可能影响几十个生成文件没有自动化检查兜底迟早出事。5. 常见问题与排查技巧实录这部分是实战中踩坑最多的领域。模板代码生成工具用好用坏很多时候就差在这些细节上。我挑几个最有代表性的问题具体说说。5.1 模板调试的“黑盒”焦虑模板引擎最让人头疼的就是调试困难。写了模板渲染报错报错信息指向模板文件第几行但你盯着那一行看了半天也不知道问题出在哪。尤其是嵌套循环加条件判断写在一起的时候可读性会急剧下降。我常用的排查手段有三个第一把数据模型打印出来确认渲染时传入的参数是不是自己预期的值。很多时候变量名写错、数据为null问题根本不在模板而在数据模型。第二把模板刻意改到最简只保留出错的区域用“最小化复现”的思路定位。第三用到FreeMarker的#if调试输出临时在模板里打印中间变量。#-- 临时调试输出当前循环的字段名 -- #list fields as field #-- 如果类型为空强制输出一条警告 -- #if !field.javaType?? WARN: field ${field.fieldName} 缺少javaType /#if /#list5.2 编码、换行符、缩进生成文件不整齐的元凶很多人第一次跑通模板生成时激动劲还没过就发现了一个尴尬的问题生成出来的代码缩进乱七八糟注释的位置对不齐。这不是模板逻辑错了而是模板文件本身的编码或换行出了状况。FreeMarker模板文件建议统一保存为UTF-8并且在Configuration里显式设置编码否则在Windows环境很容易出现中文乱码。另一个细节是换行符Windows下模板文件默认是CRLF生成出来的代码也会带上\r这在Linux环境提交代码时会引发大量“行尾差异”的告警。我的做法是在生成逻辑里做了统一处理渲染完成后把所有\r\n替换成\n。String content template.process(dataModel); // 统一使用LF换行 content content.replace(\r\n, \n);5.3 模板与业务代码的同步维护问题最后一个大问题也是模板生成工具推广中最大的阻力模板更新和已生成代码的同步。比如Service模板从V1升级到V2增加了新的日志埋点但以前已经生成的那些Service文件并不会自动重新生成。这时候就会出现新旧代码风格不一致的问题。这个问题没有一个完美的解法但有一个比较务实的策略把生成结果纳入版本管理并约定好“重新生成即覆盖”的规则。也就是说只要某个文件是由模板生成的那就认为它不应该被手改。如果模板升级了就执行一次全量重新生成所有手改痕迹都会被覆盖掉所以必须从流程上约定“模板生成的文件不允许手改”。实际操作中的做法是在所有生成文件的头部加上一行固定的注释标记比如// Generated by CodeGen. Do not modify manually.然后写一个CI检查脚本扫描所有带这个标记的文件比对它们跟模板生成结果是否一致。不一致就直接让CI失败逼着大家走重新生成的路而不是偷偷改生成代码。5.4 常见问题速查表问题现象可能原因解决方法生成代码中文乱码模板文件编码不正确或Configuration未设置UTF-8模板文件保存为UTF-8代码里setEncoding生成文件多了\rWindows环境CRLF换行渲染后统一替换为\n报错找不到变量数据模型没有该属性或属性名拼写错误打印数据模型核对属性名生成代码编译不过模板里import遗漏或类型映射不全对照手写正确代码检查模板模板更新后老代码没变没有执行重新生成跑全量生成用CI检查覆盖嵌套循环缩进乱模板里缩进用的空格和Tab混用统一缩进风格推荐4个空格个人使用中的几点体会聊了这么多最后说几句实在话。模板代码生成工具不是银弹它的核心价值在于把那些确定性极强的重复劳动自动化把团队规范固化到生成流程里。但它也有很明显的边界当业务逻辑足够复杂、每个模块差异足够大时强行套模板反而会成为一种束缚。我见过不少团队为了“提高效率”强推代码生成最后生成的代码里塞满了用不到的扩展类反而比手写更臃肿。我自己现在的一个判断标准是如果一个模块在所有项目里重复出现的概率超过七成那就值得做成模板如果每个模块都长得不太一样那就别硬套。代码生成器的维护成本是真实存在的模板本身的版本管理、测试覆盖率、与新框架的适配都需要持续投入。做之前先想清楚一个问题你到底是想消灭重复还是只是想逃避一次性的手写如果是后者那生成器只会给你带来新的重复。模板代码生成工具真正顺手的形态不是那种“一键生成整个系统”的庞然大物而是贴合自己团队技术栈、能随时调整的轻量小工具。从几张表开始从最核心的CRUD开始跑通流程后再慢慢加模块。记住让工具服务于代码规范而不是让代码规范反过来迁就工具。这套思路无论你是第一次接触还是已经趟过一些水都建议先沉淀一套自己的数据模型再动手写第一条模板。