news 2026/10/1 18:33:21

SpringBoot+Freemarker实现代码生成器:模板设计、数据模型与实战踩坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Freemarker实现代码生成器:模板设计、数据模型与实战踩坑

说实话,"代码生成"这几个字在很多初学者眼里挺神秘的,总觉得像是某种黑魔法,敲个命令就能从数据库里变出一整套CRUD代码。但真正自己动手用SpringBoot搭过一遍就会发现,它本质上就是一件事:把表结构信息填进提前写好的模板里,然后输出成文件。道理通了,剩下的就是工程细节。

上篇我们聊了然然管理系统的基础架构和集成方案,这篇把代码生成功能的后半段补完,重点放在Freemarker模板设计、数据模型构建、核心生成逻辑以及我在实际开发中踩过的坑。整套代码都在SpringBoot+MyBatisPlus+Freemarker这套组合下实现,如果你也正好在搞类似的内部脚手架,可以直接对照着改。

1. 代码生成的整体设计与思路拆解

1.1 为什么选Freemarker而不是其他模板引擎

先说选型问题。市面上能干这活的模板引擎不少,Velocity、Thymeleaf、FreeMarker三足鼎立,还有不少人直接用String拼接。我在设计然然管理系统的时候,第一版确实用过StringBuilder去拼代码,拼到Controller层的时候果断放弃了——但凡字段多一点,那段拼接代码比生成的代码还难维护,改一个缩进都能让人崩溃。

后来对比了一圈,Freemarker最终胜出,原因很实在:

  • 语法轻量:模板里无非就是${}插值、<#if>判断、<#list>循环这几种标签,后端程序员基本看两眼就会写,不像JSP那套还得理解生命周期。
  • 类型适配好:Freemarker对Java对象的属性访问很直接,${table.comment}这种写法在渲染时自动调用getter,配合MyBatisPlus的实体类用起来非常顺手。
  • 模板文件独立:.ftl文件可以直接放在resources/templates目录下,配合IDE的模板插件实现实时预览,改完模板立刻能看到效果。
  • SpringBoot集成成本低:引入spring-boot-starter-freemarker之后,SpringBoot会自动配置FreeMarkerConfigurer,我们只需要注入Template对象就能开渲染,省去手动管理模板引擎生命周期的麻烦。

相比之下,Velocity的停顿更新和社区活跃度让人不踏实;Thymeleaf的强项是页面渲染,放代码生成场景有点大材小用且语法更复杂。这不是说别的方案不行,而是从"代码生成器"这个特定场景出发,Freemarker的性价比最高。

1.2 代码生成器本质上是"模板+数据模型"渲染

很多教程上来就贴代码,容易把人看懵。我建议先把概念理清楚:代码生成的本质,是把动态变化的部分抽出来变成参数,把固定不变的部分固化成模板,然后通过数据模型把两者糅合到一起。

打个比方,这就像做印章。你把公司名称、日期、编号做成可以更换的字块,剩下的框架是固定的章体,每次盖章的时候,换个日期换个编号就能用。代码生成器也是这样,我们预先为Controller、Service、Mapper、Entity分别刻好章——也就是.ftl模板——然后从数据库读取表名、字段名、注释、类型,组合成数据模型,一次性压出全套代码。

所以在动手写代码之前,有两件事必须先做:

第一,梳理模板变量。拿到一张表,到底哪些信息在变?无非是类名、属性名、类型、注释、主键标识、表注释。把所有这些"可变点"列成一张清单,模板设计才不会漏。

第二,确定输出目录结构。生成出来的代码放哪里?是覆盖源目录还是单独生成到一个临时目录让开发自己拷贝?我在然然管理系统里采用的做法是:Controller、Service、ServiceImpl、Mapper、Entity生成到src/main/java对应包路径下,Mapper XML生成到src/main/resources/mapper目录下,前端页面生成到resources/templates下。每次生成的代码自动放在对应位置,不用手动搬运。

1.3 整体模块划分与调用链设计

整个代码生成功能的调用链并不复杂,核心就四步:读取数据源配置、解析表结构、准备模板数据、渲染输出文件。

用户点击"生成" → 读取配置的表名 → JDBC连接数据库获取元数据 → 解析字段信息封装成TableModel → 循环模板列表 → FreeMarker渲染 → 写入本地文件 → 返回生成结果清单

我们在然然管理系统中没有把它做成一个独立的服务,而是作为系统的一个模块集成在内,前端提供一张配置页面,填入数据源地址、包名、表名,点按钮触发生成。这样做的好处是团队内部所有人都可以通过页面操作,不需要懂代码,也方便权限控制。

模块内部的类划分大致如下:

  • TableModel:表级信息模型,承载表名、类名、注释、字段列表。
  • ColumnModel:字段级信息模型,承载列名、属性名、类型、注释、主键标识。
  • MetaDataReader:负责数据库元数据读取,屏蔽JDBC原生API的复杂度。
  • CodeGenerator:核心调度器,协调数据读取、模板渲染、文件输出。
  • FreemarkerUtil:封装模板配置与渲染方法。

等下我会逐个讲实现细节,建议大家先把这个依赖关系理清,写代码的时候思路会顺很多。

2. 模板体系设计与数据模型构建

2.1 JDBC元数据读取:表结构怎么变成Java对象

这一步是整个生成的源头,数据读不对,后面模板渲染全是无源之水。核心手段是JDBC的DatabaseMetaData接口,它给我们提供了一张表的结构画像。

直接看代码:

public TableModel loadTable(String tableName) throws Exception { Connection conn = dataSource.getConnection(); DatabaseMetaData metaData = conn.getMetaData(); ResultSet rs = metaData.getTables(null, null, tableName, new String[]{"TABLE"}); TableModel table = new TableModel(); if (rs.next()) { table.setTableName(tableName); table.setComment(rs.getString("REMARKS")); table.setClassName(underlineToCamel(tableName, true)); } ResultSet colRs = metaData.getColumns(null, null, tableName, null); List<ColumnModel> columns = new ArrayList<>(); while (colRs.next()) { ColumnModel column = new ColumnModel(); column.setColumnName(colRs.getString("COLUMN_NAME")); column.setJdbcType(colRs.getString("TYPE_NAME")); column.setComment(colRs.getString("REMARKS")); column.setNullable(colRs.getInt("NULLABLE") == DatabaseMetaData.columnNullable); column.setColumnSize(colRs.getInt("COLUMN_SIZE")); column.setPrimaryKey(isPrimaryKey(metaData, tableName, column.getColumnName())); column.setJavaType(jdbcTypeToJavaType(column.getJdbcType())); column.setPropertyName(underlineToCamel(column.getColumnName(), false)); columns.add(column); } table.setColumns(columns); return table; }

几个关键点解释一下:

  • metaData.getTables()里的REMARKS字段就是表注释,很多人在这步拿不到注释,很可能是MySQL驱动版本太老或者连接参数没配useInformationSchema=true,这是老坑了。
  • metaData.getColumns()返回的字段很多,我们只挑最关键的几个:类型、注释、大小、可空。注意TYPE_NAME返回的可能是"INT UNSIGNED"这种带修饰符的,后面要做清洗。
  • 主键判断单独查getPrimaryKeys(),因为MySQL返回的顺序不保证,最好把主键列名存成Set再判断。
  • 列名转属性名要自己做下划线转驼峰,这个函数后面单独讲,不推荐用第三方库,太简单没必要。

2.2 类型映射关系的处理

MySQL里的INT、VARCHAR、DATETIME和Java里的Integer、String、LocalDateTime不是一一对应的,所以要做一层映射。这个映射表是整个生成器准确率的关键,我在然然管理系统里用的是MyBatisPlus的JdbcTypeHandler思路,但考虑到可读性,直接用了一个静态映射Map:

private static final Map<String, String> JDBC_TO_JAVA = new HashMap<>(); static { JDBC_TO_JAVA.put("INT", "Integer"); JDBC_TO_JAVA.put("INTEGER", "Integer"); JDBC_TO_JAVA.put("TINYINT", "Integer"); JDBC_TO_JAVA.put("SMALLINT", "Integer"); JDBC_TO_JAVA.put("BIGINT", "Long"); JDBC_TO_JAVA.put("DECIMAL", "BigDecimal"); JDBC_TO_JAVA.put("NUMERIC", "BigDecimal"); JDBC_TO_JAVA.put("FLOAT", "Float"); JDBC_TO_JAVA.put("DOUBLE", "Double"); JDBC_TO_JAVA.put("VARCHAR", "String"); JDBC_TO_JAVA.put("CHAR", "String"); JDBC_TO_JAVA.put("TEXT", "String"); JDBC_TO_JAVA.put("LONGTEXT", "String"); JDBC_TO_JAVA.put("DATE", "LocalDate"); JDBC_TO_JAVA.put("DATETIME", "LocalDateTime"); JDBC_TO_JAVA.put("TIMESTAMP", "LocalDateTime"); JDBC_TO_JAVA.put("TIME", "LocalTime"); JDBC_TO_JAVA.put("BLOB", "byte[]"); JDBC_TO_JAVA.put("LONGBLOB", "byte[]"); }

这里有个细节要提醒:DECIMAL映射成BigDecimal是因为要保证精度,金额字段一旦映射成Double,后续计算出现浮点误差就是生成器埋的雷。另外TINYINT这块很多人会纠结——到底是Integer还是Boolean,我的建议是统一映射成Integer,因为MySQL里tinyint(1)和tinyint(2)本身语义就模糊,在Java层用Integer最安全,避免因为一个字段的语义错误导致全表字段类型都要改。

类型映射的另一个用途是生成Mapper XML里的jdbcType。在MyBatisPlus中,插入或更新语句通常会用到#{field, jdbcType=类型}这种写法,所以要把Java类型再映射回JdbcType枚举的字符串,例如Integer对应INTEGER,LocalDateTime对应TIMESTAMP。这块对应的反向映射也可以在渲染模板时通过Freemarker方法调用实现,后面统一讲。

2.3 模板文件的分层设计与命名规范

模板是整个生成器的灵魂,我在然然管理系统里按照三层架构把模板拆成了5个基础文件和1个可选文件:

/ resources/templates/generator/ ├── entity.java.ftl // 实体类 ├── mapper.java.ftl // Mapper接口 ├── mapper.xml.ftl // MyBatisPlus XML映射文件 ├── service.java.ftl // Service接口 ├── serviceImpl.java.ftl // Service实现类 └── controller.java.ftl // Controller接口

模板命名规则统一为"文件名.后缀.ftl",这样做的好处是用户能一眼看出这个模板生成什么类型的文件,也方便代码中按模板名拼输出文件名。举个例子,entity.java.ftl渲染后对应的输出文件就是${className}.java,这里className来自数据模型。

每个模板的内容设计要遵循一个原则:让生成的代码符合项目里已有的编码规范。比如然然管理系统的实体类用了@TableName注解、@TableId注解,那你模板里就该写这些;如果你们项目用的是@ApiModelProperty做Swagger注释,模板也要带上。模板是你们项目规范的固化体现,而不是通用的代码生成工具那种"万能贴"风格。

我这里贴一个entity.java.ftl的核心片段,方便大家理解模板语法怎么和数据模型配合:

package ${packageName}.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.LocalDate; /** * ${table.comment} * * @author auto-generator */ @Data @TableName("${table.tableName}") public class ${table.className} { <#list table.columns as column> /** * ${column.comment} */ <#if column.primaryKey> @TableId(value = "${column.columnName}", type = IdType.AUTO) <#else> @TableField("${column.columnName}") </#if> private ${column.javaType} ${column.propertyName}; </#list> }

注意几个模板技巧:

  • <#if column.primaryKey>用来判断主键字段,在PrimaryKeyColumn上生成@TableId,其他的只生成@TableField。如果遇到复合主键,MyBatisPlus的处理方式需要调整,然然管理系统里约束了业务表都用单主键,遇到复合主键的视图表直接不参与生成。
  • import部分虽然没判断类型条件,但我在然然管理系统里模板写死了所有的时间类型和BigDecimal的import——反正生成之后代码编辑器也会自动优化冗余import,生成代码时手写判断反而增加了模板复杂度,收益很低,不划算。
  • <#if column.comment?length == 0>这种情况,也就是数据库没写字段注释的,模板里最好加个默认值兜底,否则生成出来的代码注释是空的,看起来像半成品。兜底写法:${column.comment!"无注释"}或者${(column.comment)!''},直接用空字符串也行。

模板语法本身不复杂,但一个项目里同一类模板样式必须统一,否则生成的代码风格五花八门。如果团队里多人维护模板,建议把模板评审也纳入代码审查流程,模板变更一次全量重新生成一次,比对diff确认影响面。

2.4 数据模型与模板变量的完整清单

给数据模型取名字、定义属性,是这个功能里我花时间最多的一部分。设计得好不好,直接决定模板写起来爽不爽。

我在TableModel中放了这些字段:

字段类型说明
tableNameString数据库表名,如sys_user
classNameString实体类名,如SysUser
commentString表注释
packageNameString包名,如com.ranran.modules.system
authorString作者,从配置读取
columnsList<ColumnModel>字段模型列表

ColumnModel字段:

字段类型说明
columnNameString数据库列名
propertyNameStringJava属性名,驼峰
jdbcTypeString数据库类型字符串
javaTypeStringJava类型全限定名
commentString字段注释
primaryKeyboolean是否主键
nullableboolean是否允许为空
columnSizeint字段长度

这里有一个我踩过的坑:全限定名的问题。javaType如果直接存的是String、LocalDateTime这种短名称,模板里写import就很麻烦;但如果存全限定名java.lang.String、java.time.LocalDateTime,模板里又得做一步截取来生成import。我最终的做法是在数据模型组装阶段就同时存储javaTypeShort和javaTypeFull,模板里需要import时用javaTypeFull,需要声明属性时用javaTypeShort。这个小小的双字段设计,让模板清爽了很多。

2.5 自定义Freemarker方法:下划线转驼峰

数据库列名是user_name,Java属性名是userName,这个转换在Java代码中处理好了存进数据模型。但模板中偶尔也需要现做转换,比如生成Mapper XML时要把userName转回user_name作为列名。这种情况下,自己在模板里写字符串处理逻辑很痛苦,Freemarker模板毕竟不是编程语言。

好在Freemarker支持自定义方法,我把命名转换封装成一个TemplateMethodModel,注册到配置里:

public class UnderlineToCamelMethod implements TemplateMethodModel { @Override public Object exec(List arguments) throws TemplateModelException { if (arguments == null || arguments.isEmpty()) { return ""; } String value = arguments.get(0).toString(); if (value == null || value.length() == 0) { return ""; } StringBuilder result = new StringBuilder(); boolean nextUpper = false; for (char c : value.toLowerCase().toCharArray()) { if (c == '_') { nextUpper = true; } else if (nextUpper) { result.append(Character.toUpperCase(c)); nextUpper = false; } else { result.append(c); } } return result.toString(); } }

注册方法到模板变量的时候注意一下,Freemarker的setSharedVariable()可以全局注册,推荐用它,而不要每次创建Template的时候手动put进dataModel,否则生成器入口处代码会很啰嗦。

我在FreemarkerUtil初始化时统一注册了三个自定义方法:underlineToCamel(下划线转驼峰)、camelToUnderline(驼峰转下划线)、upperFirst(首字母大写)。后面写模板的时候,调用就变成:

${underlineToCamel(column.columnName)}

从代码可读性来说,这个方案远比数据模型里硬塞十几个字符串字段要优雅得多。

3. 生成器核心逻辑与实操实现

3.1 环境准备与配置项设计

开始写生成器之前,先把依赖和配置准备好。然然管理系统用SpringBoot 2.7.x,Maven管理依赖,pom里除了常规的spring-boot-starter-web、spring-boot-starter-jdbc之外,就加了MyBatisPlus和Freemarker:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

注意MyBatisPlus的mybatis-plus-boot-starter要和SpringBoot版本做好匹配,高版本的SpringBoot 3.x需要的是mybatis-plus-spring-boot3-starter,两个包名不一样,直接引老包会启动报错。这是这两年网上问得最多的问题之一。

然后是application.yml里的生成器配置:

code-generator: output-path: ./generated-code author: ranran default-package: com.ranran.modules templates: - entity.java.ftl - mapper.java.ftl - mapper.xml.ftl - service.java.ftl - serviceImpl.java.ftl - controller.java.ftl

把配置外置到YAML的好处是我不用每次生成都改Java代码,改包名、改输出路径只要动配置文件。output-path这里用相对路径还是绝对路径要谨慎,建议默认指定到一个单独目录generated-code,绝对不要默认输出到src/main/java目录——万一模板渲染出错,生成的半成品代码会污染正式源码目录,到时候还得靠Git回滚,折腾死人。宁可多一步拷贝动作,也别让生成器直接动源目录。

3.2 Freemarker配置类与模板初始化

SpringBoot集成Freemarker之后,默认的FreeMarkerConfigurer就能干活了,但我们要自定义一些东西,比如模板加载路径、默认编码、自定义方法注册。所以还是自己声明一个配置类:

@Configuration public class FreemarkerConfig { @Bean public FreeMarkerConfigurer freeMarkerConfigurer() { FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); configurer.setTemplateLoaderPath("classpath:/templates/generator/"); configurer.setDefaultEncoding("UTF-8"); Properties settings = new Properties(); settings.setProperty("template_exception_handler", "rethrow"); settings.setProperty("number_format", "0.##"); settings.setProperty("whitespace_stripping", "true"); configurer.setFreemarkerSettings(settings); return configurer; } }

这里几个配置值得展开讲:

  • templateLoaderPath设成classpath:/templates/generator/,意味着我们后面获取Template的时候只用写文件名entity.java.ftl就行,不需要每次写全路径。
  • template_exception_handler设为rethrow,这个非常重要。默认的debug模式会在模板渲染出错时输出一大段HTML调试信息,还会吞掉部分异常;rethrow会把异常直接抛出来,在生成器里能用try-catch捕获并定位到具体模板,调试效率高很多。
  • number_format设成0.##,是为了防止数据库里的BIGINT类型字段在模板中渲染出1,234这种带逗号的数字。这是Freemarker经典坑,默认的数字格式化会把长数字加上千分位,传到Java代码里直接编译失败。

3.3 核心生成器类的实现步骤

有了配置,核心生成器CodeGenerator就可以动手了。我对它的定位是:负责整条链路的编排,对外暴露一个generate(String tableName)方法,内部依次调用元数据读取、数据模型组装、模板渲染循环。

@Component public class CodeGenerator { @Autowired private FreeMarkerConfigurer configurer; @Value("${code-generator.output-path}") private String outputPath; @Value("${code-generator.author}") private String author; @Value("${code-generator.default-package:com.ranran.modules}") private String defaultPackage; @Resource private DataSource dataSource; public List<String> generate(String tableName) throws Exception { // 1. 读取表结构 MetaDataReader reader = new MetaDataReader(dataSource); TableModel table = reader.loadTable(tableName); // 2. 组装数据模型 table.setPackageName(defaultPackage); table.setAuthor(author); // 3. 获取模板配置 Configuration configuration = configurer.getConfiguration(); // 4. 按模板列表逐一渲染 List<String> generatedFiles = new ArrayList<>(); List<String> templateNames = Arrays.asList( "entity.java.ftl", "mapper.java.ftl", "mapper.xml.ftl", "service.java.ftl", "serviceImpl.java.ftl", "controller.java.ftl" ); for (String templateName : templateNames) { Template template = configuration.getTemplate(templateName); String rendered = FreeMarkerTemplateUtils.processTemplateIntoString(template, table); String fileName = resolveFileName(templateName, table); writeFile(outputPath, fileName, rendered); generatedFiles.add(fileName); } return generatedFiles; } }

写文件的方法里有几个细节:

private void writeFile(String basePath, String relativePath, String content) throws IOException { String fullPath = basePath + File.separator + relativePath; File file = new File(fullPath); if (!file.getParentFile().exists()) { file.getParentFile().mkdirs(); } // 文件已存在时做备份,避免直接覆盖 if (file.exists()) { File backup = new File(fullPath + ".bak"); if (backup.exists()) { backup.delete(); } Files.copy(file.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING); } Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); }

看到那个.bak备份逻辑了吗?这是我在实际使用中被坑出来的设计。当时团队里有人改了表结构以后重新生成代码,生成器直接把之前手写的代码覆盖了,一天白干。后来我加了这层备份——生成前如果发现文件已存在,先把旧文件复制成.bak,然后才覆盖写入。虽然不能百分之百防手误,但至少有个后悔药可以吃。

3.4 输出文件的路径与文件名解析

resolveFileName的作用是根据模板名决定生成文件的包路径和文件名。这个逻辑值得仔细设计,因为它要和项目结构对齐。

private String resolveFileName(String templateName, TableModel table) { String className = table.getClassName(); String packagePath = table.getPackageName().replace('.', '/'); switch (templateName) { case "entity.java.ftl": return packagePath + "/entity/" + className + ".java"; case "mapper.java.ftl": return packagePath + "/mapper/" + className + "Mapper.java"; case "mapper.xml.ftl": return "mapper/" + camelToUnderline(table.getTableName()) + "Mapper.xml"; case "service.java.ftl": return packagePath + "/service/" + className + "Service.java"; case "serviceImpl.java.ftl": return packagePath + "/service/impl/" + className + "ServiceImpl.java"; case "controller.java.ftl": return packagePath + "/controller/" + className + "Controller.java"; default: throw new IllegalArgumentException("未知模板: " + templateName); } }

这段代码有个容易被忽略的点:Mapper XML的路径和其他Java文件的路径规则不一样。它一般直接生成到resources/mapper下,不按包名一层层建目录。如果把XML也按com/ranran/modules/system/mapper/建目录,MyBatisPlus的mapper-locations配置就要跟着加路径,而且packages一多,目录层级深得很难受。这种混合路径方案在实践中最省心。

3.5 从配置驱动生成:支持批量生成

实际用下来我发现,单表生成虽然能用,但效率始终上不去。管理员需要频繁生成几十张表的基础CRUD时,挨个点按钮会崩溃。后来我做了个批量生成接口,支持传入表名前缀去系统表里自动匹配。

public List<String> generateBatch(String tablePrefix) throws Exception { List<String> allTables = reader.listTables(tablePrefix); List<String> generatedFiles = new ArrayList<>(); int successCount = 0; int failCount = 0; for (String tableName : allTables) { try { List<String> files = generate(tableName); generatedFiles.addAll(files); successCount++; } catch (Exception e) { failCount++; log.error("生成失败, tableName={}, error={}", tableName, e.getMessage(), e); } } log.info("批量生成完成,成功{}张表,失败{}张表", successCount, failCount); return generatedFiles; }

跌进坑里才会知道,批量生成的异常捕获千万别省。有一回我图省事,整个循环不包try-catch,结果某张表有个奇怪的字段类型解析失败,后面的表一张都没生成出来。之后改成单表异常不影响整体,哪张表失败就跳过哪张,同时在日志中记录失败原因,排查起来容易很多。

3.6 生成代码的前后端结构说明

上面一直盯着后端代码生成,其实然然管理系统的代码生成功能还包含了前端页面的模板。虽然基础框架是SpringBoot+Vue前后端分离,但管理端部分页面我们还是用Freemarker直接渲染,所以也设计了对应的list.html.ftl和form.html.ftl模板。

前端模板的数据模型更复杂一点,因为要生成表格列、表单域、校验规则,这些都要根据字段类型走不同的渲染逻辑。例如:

<#list table.columns as column> <#if column.javaType == "String" && column.columnSize?number <= 255> <el-input v-model="form.${column.propertyName}" placeholder="请输入${column.comment}" /> <#elseif column.javaType == "LocalDate" || column.javaType == "LocalDateTime"> <el-date-picker v-model="form.${column.propertyName}" type="datetime" placeholder="请选择${column.comment}" /> <#elseif column.javaType == "BigDecimal"> <el-input-number v-model="form.${column.propertyName}" :precision="2" :step="0.01" /> <#elseif column.javaType == "Integer" || column.javaType == "Long"> <el-input-number v-model="form.${column.propertyName}" :min="0" /> </#if> </#list>

前端模板的javaType判断逻辑要跟后端的字段类型映射保持一致,不然会出现"数据库里的TINYINT到Java成了Integer,前端模板里却走了String的分支"这种错位。两个模板属于同一套数据模型,但看到的信息维度不同,设计时最好画一张对照表,不然来回改很容易漏。

4. 常见问题与排查技巧实录

这一部分全是实战中真实踩过的坑,每一条都让我印象很深,整理出来给大家当速查表。

4.1 MyBatisPlus分页失效问题

生成出来的代码本身没问题,但用了IPage做分页查询时,物理分页不生效,查出来的是全表数据。这是刚集成完代码生成器最容易遇到的事,因为它跟生成的selectPage有直接关系。

排查步骤是这样的:

第一,检查Mapper里分页查询的方法签名有没有传IPage。正确的写法是IPage<SysUser> selectPage(IPage<SysUser> page, @Param("ew") Wrapper<SysUser> queryWrapper)。如果方法签名没带IPage参数,MyBatisPlus不会触发分页插件。

第二,检查是否配置了MybatisPlusInterceptor。这是最常被漏掉的一环,只引入了mybatis-plus-boot-starter还不够,必须自己声明分页插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

第三,确认PaginationInnerInterceptor的数据库类型正确。MySQL和PostgreSQL使用的方言不同,配错了分页SQL生成就能出问题,而且是静默失败,不容易察觉。

翻到底之后会发现,生成代码本身没毛病,问题出在基础配置上。这也是为什么我建议代码生成器只负责生成CRUD代码,框架级的分页配置还是得在项目启动时显式声明,不依赖生成器去处理。

4.2 Jar包部署后模板文件找不到

开发环境一切正常,打包成SpringBoot的jar包后一执行生成操作就报错:

java.io.FileNotFoundException: class path resource [templates/generator/entity.java.ftl] cannot be resolved

问题根源是模板文件被jar包封起来了,传统File读取方式访问不了jar里的路径。但对于Freemarker而言,TemplateLoader有两种实现:文件系统加载器FileTemplateLoader和类路径加载器ClassTemplateLoader。

解决方案是改用ClassTemplateLoader显式加载模板:

Configuration configuration = new Configuration(Configuration.VERSION_2_3_32); configuration.setTemplateLoader(new ClassTemplateLoader(getClass().getClassLoader(), "templates/generator"));

注意ClassTemplateLoader的第二参数是包路径内的相对前缀,不是classpath根路径。之前有人直接把参数写空字符串"",结果还是加载不到,原因就是类加载器的basePackagePath设置不对。

这个方法同时解决打包部署和生产环境的问题。另外如果公司内部把模板放在外部配置目录,也可以自定义FileTemplateLoader指向外部路径,灵活度更大一点。

4.3 生成的文件中文注释乱码

模板里的中文注释和数据库里读出来的表注释在生成的文件里都变成了???。这个问题的排查方向很明确——字符集。

第一层:数据库连接。JDBC连接串上必须带characterEncoding=utf-8,不然读出来的表注释就是乱码:

spring: datasource: url: jdbc:mysql://localhost:3306/ranran?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai

第二层:Freemarker模板读取编码。之前配置里已经设置了setDefaultEncoding("UTF-8"),这一步容易漏掉的是FreeMarkerConfigurer扫描模板文件时的编码,如果模板文件本身是以UTF-8保存的,但配置的默认编码是ISO-8859-1,同样会乱码。设置好setDefaultEncoding并确认模板文件在IDE里保存为UTF-8即可。

第三层:文件写入编码。Files.write方法我特意指定了StandardCharsets.UTF_8而非默认的Charset.defaultCharset()。这是因为Windows环境默认字符集是GBK,不指定的话,生成出来的文件用IDEA打开就是乱码。

这三层只要有一层不对,出来的结果就是乱码。排查顺序按照数据读取、模板渲染、文件输出一条链路走下来,五分钟就能定位问题。

4.4 重复生成代码导致手写修改丢失

这个问题前面提到过一次,值得单独再深入说说。代码生成器最让人头疼的不是生成不出来,而是生成出来的代码改动过,你一重新生成就又覆盖了。

我的经验是三条路并行:

  • 生成器保留.bak备份机制,手误覆盖时至少能找回。
  • 生成的代码里统一加@author auto-generator标记,全组约定这个标记标记的内容不要手动改,涉及业务逻辑另写子类或者放在Service实现里的自定义方法中。
  • 把模板文件纳入版本库管理,代码生成前先看Git diff,确认模板的改动是不是预期内的。

此外,实体类和Mapper文件的改动要格外小心。字段调整、注解增加,这些最好是改表结构后重新生成,而不是手工改生成代码。Controller和ServiceImpl里的自定义逻辑尽量下沉到额外的方法中,保证核心生成区不被污染。

还有个小工具层面的技巧:生成器可以增加一个--dry-run开关,只渲染不写文件,用控制台输出结果,方便快速核对模板改动的影响。虽然我们只是在内部工具上做了这个功能,但强烈推荐大家加上,比直接覆盖后看diff要安全得多。

4.5 SpringBoot版本太高导致的循环依赖问题

网上关于SpringBoot新版本兼容性的讨论这几年居高不下,也确实影响了我们。开始用SpringBoot 2.7一切正常,后来升级到3.x,项目启动直接报循环依赖:

The dependencies of some of the beans in the application context form a cycle

排查下来,循环依赖的根因在Service和ServiceImpl之间不加构造器注入,而是用了Setter或字段注入导致的。代码生成的Service实现类为了简洁,模板里用的是@Autowired字段注入,当业务类之间开始相互引用时,高版本SpringBoot默认不允许循环依赖,启动就崩了。

解决方案有两类:

  • 保留SpringBoot 3.x,在所有生成的代码中改用构造器注入,模板里相应修改:
private final SysUserService sysUserService; public SysUserController(SysUserService sysUserService) { this.sysUserService = sysUserService; }
  • 或者在application.yml里开启循环依赖:
spring: main: allow-circular-references: true

第一个方案治本,第二个方案治标,在团队规范里推荐用构造器注入。但也要坦白讲,MyBatisPlus的官方示例代码很多还是字段注入风格,大家在参考的时候要有自己的判断,不要照抄。

4.6 生成代码的包名与项目结构不匹配

这块的问题通常出现在团队里多个模块并存的情况下。比如生成器的默认包名是com.ranran.modules.system,但这个项目的实际结构可能是com.ranran.admin.system或com.company.project之类,生成出来的代码上面的package和import语句就全是错的,手动改起来还很费劲。

解决方案是在生成请求中显式传入本次生成的包名,不直接用配置文件里的default-package。我在前端页面加了个"目标包名"输入框,默认值从配置文件读,用户生成之前可以改,传入后端后顶替默认包名。这样一套模板可以服务多个包路径,不用为每个模块单独维护模板。

看上去是个很小的点,但这个是生成器"好用"和"难用"的分水岭——一个连包名都不能灵活配置的生成器,在真实项目里根本推不下去。

5. 实际使用中的经验总结与扩展建议

讲到这里,代码生成功能的整个实现链路其实已经闭环了:从配置到数据读取,从模板到渲染,从单表生成到批量处理,再加上各种性能和安全上的防护细节。

最后聊两个我个人的体会。

第一个关于生成的代码到底该怎么用。刚做完这个功能的时候,团队里有同事把生成器当成了万能脚手架,什么表都往里面塞,结果系统里堆了一大堆没人用的CRUD代码,比手工写还乱。后来我们把生成范围限定在业务系统的基础支撑模块上,比如用户管理、角色管理、菜单管理这些标准资源,业务逻辑特别重的模块仍然手写。代码生成器应该解放重复劳动,而不是制造新的垃圾。

第二个关于模板的演进迭代。代码生成器做完只是起点,模板的持续迭代才是它保持生命力的关键。公司里代码规范一旦有调整,比如接入了新的校验框架、改动包结构、升级了基础组件版本,模板就要跟着改。我在然然管理系统里给模板维护单独开了一个Git仓库,每次改动模板都要走Merge Request,至少两个人review,因为模板一个符号的错误,会放大到所有新生成的代码,这种问题比手写代码里的bug传播面更大,务必谨慎对待。

这套代码生成功能在然然管理系统里跑了几个月,最大的收获并不是省了多少开发工时——确实省了一些,比如权限模块的四张标准表,全量生成加微调大概半天搞定——而是让团队的代码风格肉眼可见地统一了。提交代码走上正轨之后,Review成本降下来,新人上手时打开项目也不至于满眼都是风格各异的山寨Controller。代码生成器的价值从来不在"生成"那个动作本身,而在它背后固化的那套工程规范。如果你也打算在自己的项目里做类似功能,先把规范定清楚,再说技术方案,这个顺序不能乱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 18:33:09

掩模黑区并不黑:铬膜复折射率、菲涅尔公式与 AttPSM 6% 的设计来历

《掩模版光学仿真与缺陷检测》专栏 第 6 讲 副标题:复折射率、菲涅尔公式与薄膜——掩模"黑区"的光学真相 前置知识:第 5 讲(平面电磁波与偏振) 预计阅读 32 分钟 上一讲结束时留了一个悬念:掩模的"黑区"根本不黑。一片 70 nm 厚的铬膜,在 266 nm 检…

作者头像 李华
网站建设 2026/10/1 18:32:04

基于JSP的影视创作论坛系统毕业设计:数据表设计与部署避坑指南

简介&#xff1a;面向JavaEE毕业设计场景&#xff0c;这份影视创作论坛系统资源完整覆盖从系统设计、开发实现到项目部署、答辩展示的全过程。资源共18个文件&#xff0c;压缩包约151MB&#xff0c;主要包含项目报告、答辩PPT、完整源代码、SQL数据库脚本、界面截图和三段部署辅…

作者头像 李华
网站建设 2026/10/1 18:31:47

复数与共轭复数全解析:从几何意义到运算技巧一次讲透

每次带学生复习复数这个专题&#xff0c;我都会先抛出一个问题&#xff1a;你会不会觉得“虚数”这个名字取得特别唬人&#xff1f;明明叫“虚”&#xff0c;却在信号处理、量子力学、电路分析里无处不在。其实换个角度想&#xff0c;复数就是一套把“旋转”和“伸缩”打包计算…

作者头像 李华
网站建设 2026/10/1 18:29:28

Compose Navigation 时序图深度拆解:背栈、生命周期与状态恢复全解析

最近在把团队里一个跑了三年的 Fragment 老项目整体切到 Jetpack Compose&#xff0c;别的都还好&#xff0c;唯独导航这块争议最大。有人说直接用原生 Navigation-Compose 就行&#xff0c;有人说要自己封装状态机&#xff0c;也有人说干脆用单一 Activity 自行管理页面状态。…

作者头像 李华
网站建设 2026/10/1 18:29:19

麻雀搜索算法实现三维WSN覆盖优化:建模、仿真与实战

做无线传感器网络&#xff08;WSN&#xff09;部署相关研究的人&#xff0c;大概率绕不开"覆盖"这个词。前几年大家习惯在二维平面上做优化&#xff1a;把一片矩形区域均匀撒上网格点&#xff0c;再用粒子群、遗传算法把传感器节点坐标跑一遍&#xff0c;覆盖率从70%…

作者头像 李华
网站建设 2026/10/1 18:28:38

DSH插件:把AI编程的Skill、专家、连接器串成自动化流水线

你有没有遇到过这种场景&#xff1a;手里的AI编程工具已经把Skill、专家、连接器、项目这些能力全给你配齐了&#xff0c;看起来要什么有什么&#xff0c;真到干活的时候却发现这些能力是四个独立的“孤岛”&#xff0c;没有一个东西能把它们按顺序串起来。Skill负责教模型特定…

作者头像 李华