恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于Freemarker的代码自动生成器实战:摆脱重复CRUD
首页
资讯中心
/
基于Freemarker的代码自动生成器实战:摆脱重复CRUD
基于Freemarker的代码自动生成器实战:摆脱重复CRUD
发布时间:2026/10/17 0:04:06
做过几年后端开发应该都体会过被重复CRUD代码支配的感觉。表建好了字段列出来接下来就是实体类、Mapper接口、XML、Service、Controller一套组合拳下来几千行代码基本是复制粘贴改字段名。手写一时爽维护火葬场尤其当字段有增减或者需要统一加个逻辑删除字段时所有相关文件全得跟着改一遍。后来我开始用模板引擎做代码自动生成试过几款工具最终固定在Freemarker上。这篇文章就专门聊聊基于Freemarker的代码自动生成器是怎么设计的模板怎么写数据模型怎么组织以及实际生成过程中最常见的坑。如果你也厌倦了无脑的CRUD想把手动重复劳动交给脚本这篇文章应该能给你一个可以直接抄作业的起点。1. 为什么我选择用Freemarker做代码生成1.1 代码自动生成到底解决什么问题一个典型的后端接口从数据库表到对外服务中间要经历多少层实体类负责映射字段Mapper接口声明增删改查方法Mapper XML里写SQLService层处理业务和事务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内容大致如下MapString, 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类型、注释、是否主键等ListMapString, String fields new ArrayList(); MapString, 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转Longvarchar转Stringdatetime转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 groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency如果不想依赖额外工具用JDK自带的java.util.HashMap装数据就够了。对一个小型生成器来说不必一上来就引入模板数据模型的复杂框架。3.2 核心工具类实现下面我给出一个精简但可直接运行的生成器骨架。这个代码把数据模型硬编码在main方法里实际使用时可以从数据库读取替换。public class CodeGenerator { public static void main(String[] args) throws Exception { MapString, 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); ListFieldMeta 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); MapString, 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.EntryString, 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代码里做字符串替换也可以但放在模板通过配置统一处理更直观。我习惯在生成器里维护一个Mapkey是模板名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。另外在读取数据库表注释时如果连接串里没有加characterEncodingutf8中文注释也会乱码。这一层的问题不会被Freemarker感知但最终会把乱码写进生成的文件里排查时容易混淆。记得JDBC连接串加上useUnicodetruecharacterEncodingutf8。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这个体验差别还是很明显的。我目前还把生成器接入了数据库表结构读取逻辑配合一个简单的配置类做到“指定表名 - 自动生成整套代码”下一步打算支持自定义模板组合比如生成到微服务多模块目录结构。如果你也准备做代码生成建议从最小的单表模板开始先把数据模型理顺再逐步加模板。代码生成不是炫技而是把机械劳动压缩到最低把精力留给真正有业务价值的代码。