做过几年后端开发,应该都体会过被重复CRUD代码支配的感觉。表建好了,字段列出来,接下来就是实体类、Mapper接口、XML、Service、Controller,一套组合拳下来,几千行代码基本是复制粘贴改字段名。手写一时爽,维护火葬场,尤其当字段有增减或者需要统一加个逻辑删除字段时,所有相关文件全得跟着改一遍。后来我开始用模板引擎做代码自动生成,试过几款工具,最终固定在Freemarker上。这篇文章就专门聊聊基于Freemarker的代码自动生成器是怎么设计的,模板怎么写,数据模型怎么组织,以及实际生成过程中最常见的坑。如果你也厌倦了无脑的CRUD,想把手动重复劳动交给脚本,这篇文章应该能给你一个可以直接抄作业的起点。
1. 为什么我选择用Freemarker做代码生成
1.1 代码自动生成到底解决什么问题
一个典型的后端接口,从数据库表到对外服务,中间要经历多少层?实体类负责映射字段,Mapper接口声明增删改查方法,Mapper XML里写SQL,Service层处理业务和事务,Controller层接收参数返回结果。这还不算DTO、VO、Query对象,以及最简单的分页查询。别说小公司没有严格的架构规范,一旦业务量上来,这些文件的数量会增长得很快。
我见过有人用Excel维护表结构,然后手动照着建实体类,字段一多就漏改,编译期根本发现不了。也有人用IDEA的live template,但每个新表都要重新操作一遍,只能减少一半的机械动作。真正解决问题的思路很简单:把“代码的固定骨架”抽出来做成模板,把“会变化的部分”比如类名、字段名、字段类型、注释抽出来做成数据,两者一合并,就能批量生成整套代码。
用Freemarker做代码生成,核心价值在于三个字:可复用。模板写一次,后面所有新表都能套用。更重要的是,当团队约定发生变化,比如所有Controller需要加一个@Validated注解,只要改模板文件,再重新生成一遍即可,而不是去几百个类里手工加。这就是为什么我愿意花几天时间把生成器搭起来,它长期来看是省时间的。
1.2 Freemarker和其他模板引擎/手写拼接的对比
早期我尝试过用StringBuilder硬拼。先写一段方法,用字符串拼接把实体类的字段定义构造出来。一开始觉得挺灵活,后来发现模板逻辑和业务逻辑纠缠在一起,Java代码里混着一堆空格对齐,维护起来像在解毛线团。只要格式化要求变一点,比如缩进从4个空格改成Tab,就得改生成器代码重新编译,非常痛苦。
后来了解过JavaPoet,它用Java代码API构建Java代码,类型安全做得很好,适合写库或框架级别的生成。但它的学习曲线略高,而且对于XML这种文本文件,JavaPoet帮不上忙。至于Velocity,和Freemarker功能重叠度很高,但Freemarker的模板语法我更熟悉,空值处理和宏(macro)机制也更顺手。
Freemarker本身是给网页渲染设计的,但它另一个重要用途就是代码生成。它的好处可以列一下:
- 模板是独立的
.ftl文件,不用编译,改完立即生效。 - 语法简单,会写HTML就会写模板,
${变量}、<#list>、<#if>基本够用。 - 内置空值默认值处理,不会因为某个字段缺失直接崩溃。
- 支持宏定义,可以把重复片段抽成公共模块。
- 性能不错,Configuration会缓存模板,适合循环批量生成。
当然它也有缺点,比如类型不严格,数据模型结构错了只能运行时才发现。但相比手写拼接,它已经让我从字符串地狱里解脱出来。项目里如果没有特殊约束,Freemarker几乎是最均衡的选择。
2. 核心思路:模板 + 数据模型 = 输出
2.1 数据模型的构造
Freemarker的生成原理用一句话概括:模板负责静态结构,数据模型负责动态变量。模板里写${classInfo.className},运行时Freemarker到数据模型里找classInfo对象,取出className属性,替换成实际的类名。
所以设计生成器第一步不是写模板,而是设计数据模型。数据模型统一了“业务信息”的传递格式。我一般会给每个实体构造一个Map或专门的POJO,内容大致如下:
Map<String, Object> classInfo = new HashMap<>(); classInfo.put("packageName", "com.example.demo.modules.user"); classInfo.put("className", "User"); classInfo.put("entityName", "user"); classInfo.put("tableName", "t_user"); classInfo.put("author", "YourName"); classInfo.put("date", "2025-01-20");字段信息则是列表,每个元素包含列名、Java属性名、Java类型、注释、是否主键等:
List<Map<String, String>> fields = new ArrayList<>(); Map<String, String> field = new HashMap<>(); field.put("columnName", "id"); field.put("fieldName", "id"); field.put("javaType", "Long"); field.put("comment", "主键"); field.put("isPrimaryKey", "true"); fields.add(field);这些数据无外乎三个来源:数据库表结构元数据、实体类反射、你手工维护的表结构描述文件。我比较推荐直接从数据库读取元数据,因为表和代码容易保持同步。你可以用JDBC去查information_schema.COLUMNS表,拿到列名、数据类型、注释,然后做一层类型映射,把数据库类型转成Java类型。比如bigint转Long,varchar转String,datetime转LocalDateTime,这个映射表很重要。
数据模型设计得好不好,直接影响模板的复杂程度。我的原则是:模板能看懂的数据结构,要接近自然语言。比如让模板里出现field.comment而不是field[3],可读性会好很多。
2.2 模板语法速览
如果你没用过Freemarker,这里快速过一遍。生成代码用到的语法不超过十种。
- 输出变量:
${user.name},如果user为null会报错,但可以用${user.name!}给默认空。 - 循环:
<#list fields as field> ... </#list>。 - 分支:
<#if field.isPrimaryKey == "true"> ... <#else> ... </#if>。 - 自定义变量:
<#assign className = classInfo.className + "Entity">。 - 导入宏:
<#import "common.ftl" as common>,调用<@common.copyright/>。
比如要生成实体类的属性定义,模板可以这样写:
<#list fields as field> /** ${field.comment} */ private ${field.javaType} ${field.fieldName}; </#list>这段模板循环遍历fields列表,每个字段生成注释和属性定义。Freemarker会严格按照模板里的换行和缩进输出,所以写模板时要注意格式,不然生成出来的代码缩进是乱的。后面我会专门讲这个问题。
2.3 生成流程的关键环节
代码生成的整个流程可以拆成四步:
- 准备数据模型:读取数据库表信息或解析实体类,得到classInfo和fields。
- 加载模板:用Freemarker的Configuration配置模板目录、编码、版本。
- 合并输出:调用
template.process(data, writer),将数据模型填充到模板得到代码字符串。 - 写入文件:根据目标路径和包名,把字符串写到对应的Java文件或XML文件。
这里最需要注意的是Configuration初始化。它最好只创建一次,因为Configuration会缓存模板,反复创建会引入不必要的开销。模板目录的状态也要维护好,如果用的是绝对路径,换机器就得改配置;我建议使用项目相对路径,或者通过Classpath来加载模板,打包成JAR后也能用。
另外,合并输出时writer要指定编码,否则容易出现中文乱码。一般用OutputStreamWriter包一层,指定UTF-8,这看起来是个小细节,实际上乱码问题十有八九都和编码设置有关。
3. 从零搭建一个可用的生成器
3.1 工程结构和依赖
我们先搭一个最小可用的Maven工程,不需要引入Spring,一个普通Java程序就行。工程结构大概长这样:
code-generator/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/generator/ │ │ │ ├── CodeGenerator.java │ │ │ ├── TableMetaReader.java │ │ │ └── model/ │ │ │ ├── ClassMeta.java │ │ │ └── FieldMeta.java │ │ └── resources/ │ │ └── templates/ │ │ ├── entity.ftl │ │ ├── mapper.ftl │ │ ├── service.ftl │ │ └── controller.ftlpom.xml里只需要一个Freemarker依赖,我用的是2.3.32版本,老项目用2.3.28以上问题都不大。
<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.32</version> </dependency>如果不想依赖额外工具,用JDK自带的java.util.HashMap装数据就够了。对一个小型生成器来说,不必一上来就引入模板数据模型的复杂框架。
3.2 核心工具类实现
下面我给出一个精简但可直接运行的生成器骨架。这个代码把数据模型硬编码在main方法里,实际使用时可以从数据库读取替换。
public class CodeGenerator { public static void main(String[] args) throws Exception { Map<String, Object> dataModel = new HashMap<>(); ClassMeta classMeta = new ClassMeta(); classMeta.setPackageName("com.example.demo.user"); classMeta.setClassName("User"); classMeta.setTableName("t_user"); classMeta.setAuthor("YourName"); classMeta.setDate(LocalDate.now().toString()); dataModel.put("classMeta", classMeta); List<FieldMeta> fields = new ArrayList<>(); fields.add(new FieldMeta("id", "id", "Long", "主键", true)); fields.add(new FieldMeta("user_name", "userName", "String", "用户名", false)); fields.add(new FieldMeta("age", "age", "Integer", "年龄", false)); dataModel.put("fields", fields); Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDirectoryForTemplateLoading(new File("src/main/resources/templates")); cfg.setDefaultEncoding("UTF-8"); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); Map<String, String> outputFiles = new HashMap<>(); outputFiles.put("entity.ftl", "src/main/java/com/example/demo/user/User.java"); outputFiles.put("mapper.ftl", "src/main/java/com/example/demo/user/UserMapper.java"); outputFiles.put("service.ftl", "src/main/java/com/example/demo/user/UserService.java"); outputFiles.put("controller.ftl", "src/main/java/com/example/demo/user/UserController.java"); for (Map.Entry<String, String> entry : outputFiles.entrySet()) { Template template = cfg.getTemplate(entry.getKey()); File targetFile = new File(entry.getValue()); if (!targetFile.getParentFile().exists()) { targetFile.getParentFile().mkdirs(); } try (Writer writer = new OutputStreamWriter( new FileOutputStream(targetFile), StandardCharsets.UTF_8)) { template.process(dataModel, writer); } } } }这段代码的核心思路是维护一个“模板文件 -> 输出文件”的映射关系。每执行一次,读取该模板对应的输出路径,确保父目录存在,然后合并写入。注意FileOutputStream和OutputStreamWriter嵌套时,我把编码设置到了Writer层,这是防止中文乱码的关键。
使用ClassMeta和FieldMeta这种POJO,比直接用Map更清晰,也方便后期扩展字段。比如ClassMeta里再加一个moduleName属性,Controller的RequestMapping前缀就可以自动带上模块名。
3.3 模板文件示例
模板是整个生成器的灵魂。我以实体类模板为例,看看Freemarker模板长什么样:
package ${classMeta.packageName}; import java.io.Serializable; import java.time.LocalDateTime; /** * ${classMeta.tableName} 表对应的实体类 * * @author ${classMeta.author} * @date ${classMeta.date} */ public class ${classMeta.className} implements Serializable { private static final long serialVersionUID = 1L; <#list fields as field> /** * ${field.comment} */ private ${field.javaType} ${field.fieldName}; </#list> <#list fields as field> public ${field.javaType} get${field.fieldName?cap_first}() { return ${field.fieldName}; } public void set${field.fieldName?cap_first}(${field.javaType} ${field.fieldName}) { this.${field.fieldName} = ${field.fieldName}; } </#list> }注意几个细节:field.fieldName?cap_first会把属性名的首字母大写,这样userName变成getUserName,这是Freemarker内置的字符串处理。每个字段的注释用field.comment输出,如果注释为空,可以加!变成${field.comment!},防止空指针。
Mapper接口模板就更简单了:
package ${classMeta.packageName}; import org.apache.ibatis.annotations.Mapper; /** * ${classMeta.tableName} 表 Mapper接口 */ @Mapper public interface ${classMeta.className}Mapper { int insert(${classMeta.className} entity); int updateById(${classMeta.className} entity); int deleteById(Long id); ${classMeta.className} selectById(Long id); }Service模板和Controller模板也类似,无非是导包、类注释、方法签名里出现了类名。因为类名和包名都来自数据模型,模板本身就很机械,这恰恰是它适合生成的原因。
3.4 生成后的效果
运行上面的生成器,会在目标目录生成对应的Java文件。实体类的效果大致如下:
package com.example.demo.user; import java.io.Serializable; /** * t_user 表对应的实体类 * * @author YourName * @date 2025-01-20 */ public class User implements Serializable { private static final long serialVersionUID = 1L; /** * 主键 */ private Long id; /** * 用户名 */ private String userName; /** * 年龄 */ private Integer age; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } }这就是我们想要的成品。在IDE里打开,包路径正确、类名正确、注释齐全,和手写没有区别。唯一不同的是,创建这个文件只花了几毫秒,而且不会因为字段太多而漏掉getter/setter。
4. 实操细节:如何写出易维护的模板
4.1 模板命名与目录规划
模板文件命名我建议尽量直观,后缀统一用.ftl,前面可以加目标文件的类型。比如entity.ftl、mapper.ftl、service.ftl、controller.ftl、mapperXml.ftl。如果你有不同的风格模板,比如一种用于单表CRUD,一种用于树形结构,那可以在templates下建子目录,分成crud和tree,这样生成的时候按需选择。
还有一点容易忽略:模板目录的维护。生成器有可能在多个项目里复用,如果模板写死在绝对路径,比如/Users/me/generator/templates,换一台电脑就要改代码。我推荐把模板放到当前工程的src/main/resources/templates下,用类路径加载。用Spring时可以直接通过ClassPathResource获取目录,纯Java的话可以用CodeGenerator.class.getResource("/templates")拿到URL。这样生成的jar包自带模板,部署和分发都方便。
4.2 公共片段抽取(macro)
如果你有多个模板都需要生成版权注释、都需要生成一个“定期重建”的警告说明,把这个片段复制到每个模板里就违背了DRY原则。Freemarker的宏机制可以让你把这些公共片段抽出来。
以版权头注释为例,新建一个common.ftl:
<#macro fileHeader author date> /** * 本文件由代码生成器自动生成,请勿手动修改。 * 如需调整业务逻辑,请修改模板或生成规则。 * * @author ${author} * @date ${date} */ </#macro>然后在任何模板里导入并使用:
<#import "common.ftl" as common> <@common.fileHeader author="${classMeta.author}" date="${classMeta.date}"/>这样当公司规定要在文件头加一段版权信息或免责声明时,你只需要改common.ftl,所有模板生成的文件都会同步更新。我在实际项目里把生成器的时间戳、表结构变更记录都做成了宏,模板看起来清爽很多。
除了宏,你还可以用模板继承<#assign>配合使用,但代码生成场景下宏基本够用了。模板里出现三行以上重复内容时,就考虑是否要抽成宏。
4.3 包名、路径和覆盖策略
生成器的输出路径要和Java包名对应,否则IDE里看得到文件但编译不通过。比较稳妥的方式是把输出路径定义成:
src/main/java/${classMeta.packageName?replace(".", "/")}/${classMeta.className}Mapper.java在Java代码里做字符串替换也可以,但放在模板通过配置统一处理更直观。我习惯在生成器里维护一个Map,key是模板名,value是输出路径模板,其中的${packagePath}和${className}在运行时替换。
覆盖策略也要提前想清楚。生成器最常见的坑是:旧文件被覆盖,你手工改过的代码全丢了。我的建议是,对于自动生成的代码,要么保留一个明显的“不要手工修改”注释,要么把生成文件输出到单独目录,比如src/main/java-gen,然后作为额外源目录参与编译。这样即使重新生成,也不会破坏手写业务代码。
如果非要覆盖原文件,至少要在覆盖前自动备份一份到backup目录,或者用Git管理好变更,便于diff。我在项目里就吃过亏,生成器跑一遍,把某个手写的Service实现覆盖了,万幸有版本控制才找回来。从那以后,我再也不敢把Service模板写得过于“智能”,真正需要手写的部分都留空,生成后手动补充。
5. 实战中的常见问题与排查
5.1 文件乱码问题
乱码是我遇到最多的Bug。生成出来的Java文件中文注释变成???,或者编译时报“非法字符”。原因基本绕不开三点:模板文件编码、Freemarker读取编码、输出文件编码。
排查顺序是这样的:
- 确认模板文件本身以UTF-8保存。IDEA右下角可以看文件编码,Windows下尤其容易变成GBK。
- 确认
cfg.setDefaultEncoding("UTF-8"),有些旧代码忘了加,导致Freemarker用默认的ISO-8859-1读模板。 - 确认写入文件时Writer指定了UTF-8。用
FileWriter是默认平台的编码,Windows下是GBK,所以我坚持使用OutputStreamWriter + StandardCharsets.UTF_8。
另外在读取数据库表注释时,如果连接串里没有加characterEncoding=utf8,中文注释也会乱码。这一层的问题不会被Freemarker感知,但最终会把乱码写进生成的文件里,排查时容易混淆。记得JDBC连接串加上useUnicode=true&characterEncoding=utf8。
5.2 空值处理和默认值
数据模型里某个字段缺失,Freemarker默认会抛异常,提示foo is undefined。这个设计是双刃剑,好在能暴露数据问题,坏处是偶尔一个表缺注释,整个生成流程中断。
我的处理原则是:必填信息(类名、包名)缺失必须报错,业务信息(注释、作者)缺失可以提供默认值。比如模板里用${field.comment!""},注释为空就输出空字符串。用${classMeta.author!"unknown"},作者没传就写unknown。
如果你想针对List循环处理空集合,可以在List外层加判断:
<#if fields?? && fields?size gt 0> <#list fields as field> ... </#list> </#if>??是Freemarker判断变量是否存在,?size取集合大小。模板里多写这种防御式判断,生成器的健壮性会好很多。
5.3 模板语法错误排查
Freemarker模板出错的报错信息一般会带模板文件路径和行号,比如:
TemplateModelException: Error executing FreeMarker template FreeMarker template error (DEBUG mode; use RETHROW in production!): The following has evaluated to null or missing: ==> field (in template "entity.ftl" at line 15, column 10)看到这种信息别慌,定位到实体模板第15行,看上下文就知道是field为空。通常是因为fields列表里混入了null,或者key拼错了。给列表加一个<#list fields??判断就没这个问题。
还有一种情况是<#list>和</#list>没有配对,或者嵌套时少闭合,Freemarker会报“Unexpected end of template”。这时候建议用支持Freemarker语法高亮的IDE插件编辑模板,或者写一个单元测试,把所有模板和数据模型组合跑一遍,确保语法正确。我推荐至少写一个最简单的测试用例,把每个模板都process到字符串里,断言不抛异常,这样每次改动模板都能快速回归。
5.4 生成效率与批量处理
如果只是偶尔生成几张表,性能无所谓。但你要一次生成几十张表、每个表生成8个文件,就需要注意一些细节。
Freemarker的Configuration在第一次获取模板时会解析并缓存,所以用同一个Configuration实例多次调用getTemplate是没问题的。真正影响性能的往往是IO操作,比如每次都新建FileOutputStream。我建议用try-with-resources严格管理流,同时尽量复用输出目录的判断逻辑。
另外,Freemarker默认对数字的格式化会有逗号分隔,比如金额1234567会输出成1,234,567,这在生成代码时很要命,因为方法参数里的数字可能变成千分位格式。需要在Configuration里设置数字格式:
cfg.setNumberFormat("0");这是一个很多人会忽略的大坑。如果你生成代码里包含serialVersionUID = 123456789L,不设置NumberFormat,结果会变成123,456,789L,编译直接报错。
6. 最后分享一点个人心得
关于代码生成器,我用Freemarker折腾了几年,最大的心得是“模板宁简勿繁”。模板里写的逻辑越简单,它就越可靠,越容易维护。不要把业务判断都堆进模板,如果模板里出现大量<#if>和嵌套循环,说明你的数据模型设计不到位,应该在生成器代码里预处理出贴近最终输出的数据结构。
另一个小技巧是把模板目录和生成器打包成可执行JAR,放到项目里以后,新接手的同事只要跑一条命令或者一个main方法,就能生成全套代码。配合Maven的exec-maven-plugin,甚至可以用mvn compile exec:java一步完成。对比让新人手敲一遍CRUD,这个体验差别还是很明显的。
我目前还把生成器接入了数据库表结构读取逻辑,配合一个简单的配置类,做到“指定表名 -> 自动生成整套代码”,下一步打算支持自定义模板组合,比如生成到微服务多模块目录结构。如果你也准备做代码生成,建议从最小的单表模板开始,先把数据模型理顺,再逐步加模板。代码生成不是炫技,而是把机械劳动压缩到最低,把精力留给真正有业务价值的代码。