前几年我还在靠复制粘贴写CRUD的时候,最大的愿望就是有个东西能替我把那堆重复的增删改查代码一口气全写出来。后来真正深入去研究代码生成器,才发现这玩意儿并没有那么玄乎,核心就是三板斧:摸清楚业务表的元数据、找一套顺手的模板引擎、再把生成规则沉淀成配置。今天这篇就从一个实际开发者的角度,把代码生成器从设计到落地的完整链路掰开揉碎讲清楚。
先说我做这个东西的背景。团队后端技术栈是Spring Boot + MyBatis-Plus,前端是Vue3 + Element Plus,每次接一个新模块,光写entity、mapper、service、controller、vue页面这些基础代码就要磨蹭一两天,而且出错率极高,字段名手误、类型对不上、关联查询漏条件,全是细节问题。后来我决心基于若依框架的代码生成思路,自研一套适配团队业务的代码生成器,从设计原理、模板编写到实际改造,整个过程跑通之后,新模块的开发效率至少提升了50%,我现在把完整经验分享出来。
1. 代码生成器的设计原理与整体架构
1.1 为什么需要自研代码生成器
市面上现成的生成工具不少,最典型的就是若依(RuoYi)自带的代码生成模块,此外还有MyBatis Generator、MyBatis-Plus Generator等。说实话,直接用这些工具确实能解决大部分问题,但落到真实业务里,总能碰到一些别扭的地方:
- 模板是写死的,如果想在生成的entity里自动加一段创建人、更新人的审计字段注释,改起来费劲。
- 生成风格和团队规范未必一致。比如我们团队要求所有service接口都必须有@Transactional注解标记,但默认模板里并没有,每次生成完还得手动补。
- 字段类型映射规则单一。比如数据库里有个
deleted字段,我们希望它自动映射成Boolean类型且加@TableLogic注解,这个默认模板做不到。
自研的成本其实没有想象中高,核心就两件事:读取表结构的元数据 + 把元数据塞进一套可维护的模板里。搞清楚了这两件事,后续想加什么花活都是自由发挥。
注意:自研不代表推翻重来,恰恰相反,我建议先深入研究若依这类成熟框架的生成逻辑,把优秀的设计思路借鉴过来,再针对自己的项目骨架做定制。站在巨人的肩膀上改进,永远比从零造轮子靠谱。
1.2 整体架构分层设计
我设计的代码生成器分为四层,每层职责非常清晰:
| 层级 | 核心职责 | 关键组件 | 具体说明 |
|---|---|---|---|
| 数据源层 | 读取数据库表结构 | JDBC连接、DatabaseMetaData | 获取表名、字段名、字段类型、注释、主键信息 |
| 元数据层 | 将原始信息转换为可用的Java模型 | TableInfo、ColumnInfo | 屏蔽不同数据库的差异,统一字段命名和类型映射 |
| 模板引擎层 | 根据模板生成代码文件 | Velocity / FreeMarker / Thymeleaf | 决定输出格式和内容结构 |
| 输出层 | 管理文件生成策略 | 覆盖策略、目录映射、格式化处理 | 将生成的代码写入目标路径 |
只要把数据源层的信息取准了,后面三层都是确定性工作。说白了,代码生成器就是一个管道:数据库表结构进去,一套完整代码出来。任何一个环节的精准度不够,最终生成的代码质量都会打折扣,所以每一层我都有针对性的设计。
1.3 基于若依思路的扩展设计
若依代码生成器其实是这个领域的一个标杆实现。它的思路很值得学习:配置数据源 -> 导入数据表 -> 编辑字段配置(列表显示、表单类型、查询方式等) -> 选择模板生成 -> 下载代码或直接生成到项目目录。
我在自研时保留了这套核心流程,但做了三处关键扩展:
- 配置信息入库。若依把每张表生成的配置存到
gen_table和gen_table_column两张表里,我同样设计了两张配置表,这样二次调整字段配置时不用反复读取数据库,而且支持同一张表配置多套生成方案(比如PC端和移动端各一套)。 - 模板热加载。我把模板文件放在一个独立的
template目录下,修改模板后不用重启服务,定时扫描检测到文件变更就自动重新加载。这个功能在调试模板时非常省事,配一套模板调一个星期是常态。 - 远程生成和本地生成双模式。团队开发时用远程模式把代码生成到Git仓库统一的目录,个人调试时用本地模式输出到临时文件夹,互不干扰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心模块的选型分析与技术决策
2.1 模板引擎的对比与选择
这是整个代码生成器最核心的选型决策。模板引擎本质上解决的是“把元数据和文本模板混合渲染”这个问题,市面主流选项有FreeMarker、Velocity和Thymeleaf,各有优缺点:
| 模板引擎 | 性能表现 | 语法友好度 | 中文社区活跃度 | 特点 |
|---|---|---|---|---|
| FreeMarker | 高 | 中,宏功能强大 | 高 | 功能全面,适合复杂模板,循环/判断/宏定义非常灵活 |
| Velocity | 较高 | 高,语法简单 | 中(处于维护状态) | 上手极易,模板写起来直观,但近年更新缓慢 |
| Thymeleaf | 中 | 中(面向HTML场景更优) | 中 | 主要用于Web视图渲染,在纯代码生成场景优势不明显 |
我最终选择了FreeMarker。原因有三:一是它的宏定义能力非常强,可以把“字段列表展示”这类重复段落封装成宏,多个模板复用;二是官方文档完善,遇到语法问题基本一搜就有答案;三是若依用的就是Velocity,但我在改造中觉得FreeMarker的类型转换和空值处理更严谨,更能适应复杂模板场景。
提示:選模板引擎不要只盯着性能。代码生成器跑一次最多几十毫秒,性能再差都无所谓。真正重要的是语法是否顺手、容错是否友好、团队有没有人熟悉。我们团队之前没人用过FreeMarker,但我会,所以选了它,后期带新人也有现成的参照。
2.2 自定义标签的设计思路
FreeMarker虽然强大,但直接用原生指令写模板,里面还是会有大量<#if>、<#list>嵌套,维护起来很痛苦。我在模板层之上封装了一套自定义指令,让模板看起来像是正常代码:
ftl复制// 原来的写法
<#list table.columns as col>
${col.javaField} ${col.javaType}
</#list>
// 封装后
<@genFieldList table.columns />
自定义指令的好处是,模板编写人员不需要懂FreeMarker语法,只需要认识几个简单的自定义标签就行。比如<@genImport />自动生成import语句,<@genEntityField />生成entity字段及注解,<@genQueryCondition />生成查询条件代码。这样后来维护模板的同事,只需要看我写好的标签说明就能上手。
2.3 代码合并策略的取舍
代码生成器最招人烦的一点是覆盖风险。比如生成Service后,又在里面手写了一段业务代码,结果因为改了个表结构重新生成,手写代码直接被覆盖了。针对这个问题我做了三个层级的取舍:
- 层级一:文件级保护。生成的时候先检查文件是否存在,默认情况下不覆盖,而是输出到
_generated备份目录,人工核对后再手动替换。 - 层级二:方法级追加。通过自定义标签,在模板里定位
// gen:custom:start和// gen:custom:end标记之间的代码,合并时保留该区域内的手写内容。 - 层级三:代码对比工具。生成完成后自动调起Beyond Compare或IDE的diff视图,让开发者直观看到差异,而不是默默覆盖。
实际使用中,第二级“方法级追加”用得最多,也最实用。比如一个分页查询方法,生成的默认逻辑是从表里无条件查询所有数据,但我往往要加一层数据权限过滤。这时候我只需要在模板里预留自定义区域,重新生成后那部分手写逻辑会原样保留,极其省心。
2.4 字段命名与类型映射的踩坑记录
这是我在项目里踩坑最多的地方。数据库字段明明叫user_name,转成Java后自然应该是userName,但如果表里有个字段叫uId,有的工具会生成uId,有的会生成uid,然后在Linux环境下的Git敏感大小写设置里莫名奇妙就把代码搞乱了,不得不统一规范。
我在元数据层专门做了一套命名转换规范,依赖开源的hutool工具的StrUtil.toCamelCase方法做底层转换,再包裹一层自己的规则处理:
java复制public class ColumnNameConverter {
public static String toJavaFieldName(String columnName) {
// 先走驼峰转换
String camelName = StrUtil.toCamelCase(columnName.toLowerCase());
// 处理特殊前缀,比如is_前缀去掉避免生成isIsDelete
if (camelName.startsWith("is") && camelName.length() > 2
&& Character.isUpperCase(camelName.charAt(2))) {
camelName = camelName.substring(2);
}
// 处理保留字冲突,比如status、order、group这些字段名会自动加后缀
if (RESERVED_WORDS.contains(camelName)) {
camelName = camelName + "Field";
}
return camelName;
}
}
类型映射这块,我维护了一份MySQL到Java类型的映射表,核心映射是:varchar -> String、bigint -> Long、int/integer -> Integer、datetime/timestamp -> Date、decimal -> BigDecimal。但有几个业务特例需要特殊处理:tinyint(1)我映射成Boolean,因为业务上它就是布尔值;bigint unsigned映射成Long,避免精度丢失;json类型映射成String,后续配合MyBatis-Plus的TypeHandler处理,比直接映射成复杂对象更稳妥。
3. 实操过程:手把手实现一个代码生成器核心流程
3.1 环境准备与基础设施搭建
做代码生成器不需要特别复杂的环境,我用的技术栈是:
- JDK 1.8+
- Spring Boot 2.7.x
- FreeMarker 2.3.32
- MySQL 8.0(元数据读取和配置存储)
- Hutool工具库(字符串处理、文件操作)
工程结构上我把它做成一个独立的Spring Boot子模块gen-tool,不直接改原业务服务,避免生成器自身的依赖污染业务工程。在pom.xml中引入关键依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.18</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
3.2 元数据读取:摸清数据库表结构
我写了一个TableMetadataService,核心逻辑是用JDBC的DatabaseMetaData读取表信息。这一步是整个工具的地基,取不准后面全白搭:
java复制@Service
public class TableMetadataService {
@Resource
private DataSource dataSource;
public TableInfo getTableInfo(String tableName) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
// 读取表基本信息
try (ResultSet tableRs = metaData.getTables(
conn.getCatalog(), null, tableName, new String[]{"TABLE"})) {
if (!tableRs.next()) {
throw new IllegalArgumentException("表不存在: " + tableName);
}
String tableComment = tableRs.getString("REMARKS");
// 其他表信息...
}
// 读取字段信息
List<ColumnInfo> columns = new ArrayList<>();
try (ResultSet colRs = metaData.getColumns(
conn.getCatalog(), null, tableName, "%")) {
while (colRs.next()) {
ColumnInfo col = new ColumnInfo();
col.setColumnName(colRs.getString("COLUMN_NAME"));
col.setColumnType(colRs.getString("TYPE_NAME"));
col.setColumnSize(colRs.getInt("COLUMN_SIZE"));
col.setColumnComment(colRs.getString("REMARKS"));
col.setNullable(colRs.getInt("NULLABLE") == DatabaseMetaData.columnNullable);
columns.add(col);
}
}
// 读取主键信息
try (ResultSet pkRs = metaData.getPrimaryKeys(conn.getCatalog(), null, tableName)) {
if (pkRs.next()) {
tableInfo.setPrimaryKey(pkRs.getString("COLUMN_NAME"));
}
}
return buildTableInfo(tableName, tableComment, columns);
}
}
}
这里有个细节必须提一下:MySQL里getTables返回的REMARKS字段就是表注释,但有些版本驱动需要我们在建表语句里指定COMMENT,否则读出来是空的。同时getColumns返回的TYPE_NAME是数据库原生类型名(比如VARCHAR),而不是JDBC类型码,所以我们的类型映射一定要基于原生类型名来处理。我实际测试发现MySQL Connector/J 8.x在读取视图时getTables返回的TABLE_TYPE是VIEW而不是TABLE,所以如果想连视图一起生成,上面的过滤条件要放宽。
3.3 元数据模型:统一数据标准
拿到了原始元数据还不能直接用,我把它转换成业务通用的TableInfo和ColumnInfo两个模型。TableInfo里除了表名、表注释,还预计算好了类名(大驼峰)、实体名(小驼峰)、路由前缀等;ColumnInfo里预计算好了Java类型、Java字段名、是否主键、是否逻辑删除、是否自动填充等属性。
这里的关键设计在于,把“计算”和“渲染”完全分离。模板里只做取值和判断,所有复杂的命名规则、类型映射、注解推断都在Java代码里提前处理完,这样模板看着清爽,逻辑也方便单元测试。
java复制public class ColumnInfo {
private String columnName; // 原始列名,如 user_name
private String javaFieldName; // 驼峰字段名,如 userName
private String javaType; // Java类型,如 String
private String columnComment; // 字段注释
private boolean primaryKey; // 是否主键
private boolean logicDelete; // 是否逻辑删除字段
private boolean autoFill; // 是否自动填充(创建时间/修改时间)
private String queryType; // 查询方式:EQ/LIKE/BETWEEN
private String formType; // 表单控件类型:input/select/date
}
在实际项目中,我会在读取元数据后立刻把queryType和formType的默认值计算好:字符串类型默认LIKE查询、表单用input;数值类型默认EQ查询、表单用input数字框;日期类型默认BETWEEN查询、表单用date范围选择器。这样导入表之后几乎不需要再手动调整字段配置,可以直接生成,效率提升非常明显。
3.4 模板编写:以Entity和Service为例
元数据准备好了,接下来就是写模板。模板放resources/templates/目录,我按文件类型建了子目录:entity.ftl、mapper.ftl、service.ftl、serviceImpl.ftl、controller.ftl、vue/index.vue.ftl等。
Entity模板的核心片段:
ftl复制package ${packageName}.entity;
<@genImport table.columns />
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* ${table.tableComment}
*
* @author ${author}
* @date ${date}
*/
@Data
@TableName("${table.tableName}")
public class ${table.className} implements Serializable {
private static final long serialVersionUID = 1L;
<#list table.columns as col>
/**
* ${col.columnComment}
*/
<#if col.primaryKey>
@TableId(type = IdType.AUTO)
</#if>
<#if col.logicDelete>
@TableLogic
</#if>
<#if col.autoFill>
@TableField(fill = FieldFill.INSERT)
</#if>
private ${col.javaType} ${col.javaFieldName};
</#list>
}
Service模板我采用了“接口 + 实现类”的双层结构,符合团队规范。实现类继承MyBatis-Plus的ServiceImpl,自动获得基础CRUD能力:
ftl复制package ${packageName}.service.impl;
import ${packageName}.entity.${table.className};
import ${packageName}.mapper.${table.className}Mapper;
import ${packageName}.service.${table.className}Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
/**
* ${table.tableComment} 服务实现类
*
* @author ${author}
* @date ${date}
*/
@Service
public class ${table.className}ServiceImpl extends ServiceImpl<${table.className}Mapper, ${table.className}>
implements ${table.className}Service {
// gen:custom:start
// 此处方法会被保留,不会被覆盖
// gen:custom:end
}
注意上面的// gen:custom:start和// gen:custom:end注释,这就是之前说的方法级追加保护。合并代码的逻辑在输出层实现,如果检测到目标文件里存在这对标记,就提取中间的代码,重新生成时拼接到新内容里。
3.5 合并与输出:守护手写代码
代码合并我单独写了一个CodeMerger类,处理逻辑比较直观:
java复制public class CodeMerger {
private static final String CUSTOM_START = "// gen:custom:start";
private static final String CUSTOM_END = "// gen:custom:end";
public String merge(String generatedCode, String oldCode) {
if (oldCode == null || oldCode.isEmpty()) {
return generatedCode;
}
// 从旧代码中提取需要保留的自定义区域
int startIdx = oldCode.indexOf(CUSTOM_START);
int endIdx = oldCode.indexOf(CUSTOM_END);
if (startIdx >= 0 && endIdx > startIdx) {
String customBlock = oldCode.substring(startIdx + CUSTOM_START.length(), endIdx);
// 替换新代码中的空自定义区域
String marker = CUSTOM_START + "\\s*" + CUSTOM_END;
return generatedCode.replaceAll(marker, CUSTOM_START + "\n" + customBlock + "\n" + CUSTOM_END);
}
return generatedCode;
}
}
这块逻辑我在实际开发中不断打磨过。最早设计是“完全覆盖”,后来被同事吐槽过一次,手写的业务代码没了,两天的工作量消失,这教训太深刻。现在默认策略改为“凡是存在旧文件一律合并输出,且合并后的内容输出到新目录,让IDE的diff来找差异”,除非在参数里明确传overwrite=true,否则绝不直接覆盖。
3.6 若依代码生成器使用:一次完整推导
如果你不想从零自己写,直接用若依的代码生成器也是个极好的选择。我把自己改造过程中参考的若依使用流程完整梳理一遍,顺便标出哪些环节是值得借鉴的。
假设业务表是sys_student,要在若依框架下生成整套CRUD:
-
在若依后台导入表结构。进入“系统工具 -> 代码生成”,点击“导入”按钮,选择
sys_student表。这一步若依会读取表字段信息,拿到所有列名、类型、注释、主键。 -
编辑字段配置。这是若依最有价值的地方。每列可以配置插入、编辑、列表、查询四种属性。比如
student_name字段:列表显示打勾、插入和编辑打勾、查询类型选LIKE、表单控件类型选input;sex字段:表单控件类型选select,同时配置字典类型sys_user_sex;birthday字段:表单控件类型选date。 -
配置生成信息。设置包名
com.ruoyi.system、模块名system、业务名student、生成方式(可下载压缩包,也可直接生成到项目),模板默认是Velocity引擎。 -
生成代码。点击“生成代码”,若依会生成一个压缩包,里面包含
domain、mapper、service、controller、mapper.xml以及Vue页面和后端对应的路由SQL。把压缩包解压放到工程相应目录,重启后菜单和接口就都能用了。
那套流程我最初是照着用的,确实能跑通,但拿到手里之后发现一个问题:若依生成的Vue组件用的是它自己封装的parseTime、resetForm这些公共方法,如果团队前端不是基于若依的Admin模板开发的,这些内容就用不上,需要手动改大量地方。所以我在自研时特意把模板拆分得更细,前端模板直接输出标准Element Plus组件,不依赖任何框架自带的封装方法,这样团队内即使不用若依框架也能无缝接入。
4. 进阶优化与工具链整合
4.1 模板热加载机制
调试模板是最烦的过程之一,改一行模板、重启一次服务,效率非常低。我实现了模板热加载,核心思路是在Spring的FreeMarker配置里做一层自定义的TemplateLoader。
方法是扫描classpath:templates/和外部文件目录./gen-templates/,如果外部目录文件存在,就优先从外部加载,否则从classpath加载。这样改模板只需要刷新页面重新生成,不用重启服务。实测下来这功能至少帮我节省了一半模板调试时间。
Spring Boot下的简化配置:
java复制@Configuration
public class FreeMarkerConfig {
@Bean
public FreeMarkerConfigurer freeMarkerConfigurer() {
FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
configurer.setTemplateLoaderPaths("file:./gen-templates/", "classpath:templates/");
configurer.setDefaultEncoding("UTF-8");
Properties settings = new Properties();
settings.setProperty("template_update_delay", "0");
settings.setProperty("default_encoding", "UTF-8");
settings.setProperty("output_encoding", "UTF-8");
configurer.setFreemarkerSettings(settings);
return configurer;
}
}
注意template_update_delay这行,默认FreeMarker会缓存模板,值设为0就是为了每次渲染都重新读取文件。生产环境如果对性能有极致要求可以调大,但对于生成工具完全不需要考虑缓存,怎么方便怎么来。
4.2 多数据源支持
我们团队同时维护MySQL和PostgreSQL两套数据库,所以生成器也必须支持多数据源。我在配置表里加了db_type字段,元数据读取和类型映射都基于这个字段路由。
MySQL和PostgreSQL的元数据API有细微差别。比如PostgreSQL里读取表注释要用getTables配合TABLE_TYPE,但字段类型的名字可能带空间类型参数,比如character varying,和MySQL的varchar长得完全不一样。我的类型映射类里写了两套映射规则,核心结构是:
java复制public class TypeMapping {
private static final Map<String, String> MYSQL_TYPE_MAP = new HashMap<>();
private static final Map<String, String> POSTGRES_TYPE_MAP = new HashMap<>();
static {
MYSQL_TYPE_MAP.put("varchar", "String");
MYSQL_TYPE_MAP.put("bigint", "Long");
MYSQL_TYPE_MAP.put("datetime", "Date");
// ...
POSTGRES_TYPE_MAP.put("character varying", "String");
POSTGRES_TYPE_MAP.put("int4", "Integer");
POSTGRES_TYPE_MAP.put("int8", "Long");
POSTGRES_TYPE_MAP.put("timestamp", "Date");
// ...
}
public static String mapType(String dbType, String columnType) {
String normalizedType = columnType.toLowerCase();
if (normalizedType.contains("(")) {
normalizedType = normalizedType.substring(0, normalizedType.indexOf("("));
}
return switch (dbType) {
case "mysql" -> MYSQL_TYPE_MAP.getOrDefault(normalizedType, "String");
case "postgresql" -> POSTGRES_TYPE_MAP.getOrDefault(normalizedType, "String");
default -> "String";
};
}
}
把差异全部收敛在映射表里之后,上层模板几乎不需要为不同数据库写两套逻辑,省心很多。
4.3 断言与单元测试保障
代码生成器如果生成错代码,后面纠错的成本是非常高的。所以我在输出层之前加了一层“断言校验”,保证生成结果不出现早期问题。
- 类名非空且合法:如果表名转换出来的类名是Java保留字,或者以数字开头,直接报错,不生成文件。
- 主键是否存在:没有主键的表默认不生成,除非在配置里显式声明“无主键模式”。
- 字段名是否重复:经过驼峰转换后,如果两个数据库字段转换成同一个Java字段名,比如
user_name和userName,程序会抛异常,避免生成同名重复字段。
单元测试方面,我为每个模块写了测试用例。最核心的是CodeMergerTest和TypeMappingTest,一个验证合并逻辑的正确性,一个验证常见数据库字段类型的映射结果。还有一组模板基准测试,用一张预先设计好的“万能表”(涵盖全部字段类型)跑一次完整生成流程,然后用文本快照比对结果,任何模板改动导致输出变化都能第一时间发现。
4.4 与CI/CD流水线集成
我把生成器包装成了命令行工具,支持不带界面直接跑。在pom.xml里配置了spring-boot-maven-plugin的mainClass指向命令行启动器,打包出可执行jar包后,用一行命令触发生成:
bash复制java -jar gen-tool.jar --table=sys_student --module=system --author=zhangsan
这样就能在CI流水线里接一个步骤:代码合并进主干后,自动检测数据库中新增的表,自动生成基础代码,并推送到指定仓库的generated目录,开发者只需要拉代码审查。虽然这个自动化程度已经很高了,但我的经验是生成出来的代码一定要有人工review环节,完全信任自动生成的结果容易埋雷。
4.5 支持代码格式化
生成的代码格式化是个容易被忽视的痛点。FreeMarker模板里为了保持缩进结构,经常嵌套很多循环判断,输出的代码经常带着乱七八糟的空行和缩进。我在输出层接入了google-java-format库,对生成的Java代码统一格式化,前端Vue代码则调用prettier的Node接口格式化,这样不管模板怎么写,最终产出的代码风格都统一清爽。
格式化这步要在文件输出到目录之前就完成,因为生成过程中如果发现格式化异常,说明模板逻辑有误,应该直接暴露而不是生成带病代码。
5. 常见问题与排查实录
5.1 表注释和字段注释读不出来
这是新手最容易踩的坑,明明数据库表里写了COMMENT,程序读出来却全是空或null。
排查思路分三步:先确认建表语句是不是真的带了COMMENT,有些可视化工具创建的表可能没写注释,这是表设计阶段的坑,骗不了代码;再确认数据库连接串有没有加useInformationSchema=true参数,尤其是MySQL 8.x连接串加入这个参数能拿到更多元数据信息;最后检查是不是用了连接池的缓存,Druid某些配置下元数据会有缓存,清一下缓存或重新获取connection再看。
我最终的处理方案是在项目中增加一个“元数据预览”页面,读取完成后把表注释、字段注释、字段类型以表格形式展示出来,一眼就能发现哪些注释丢了,避免生成时才意识到问题。
5.2 生成的代码中文注释乱码
这个问题十有八九出在FreeMarker的编码配置上。我一开始直接在application.yml里配了default_encoding: UTF-8,但生成的Java文件里中文注释依然乱码。最后排查发现是输出文件的Writer没有指定编码,FreeMarker渲染完了,写文件时用了默认编码。
解决方法是输出层写文件时显式指定UTF-8:
java复制String content = template.process(dataModel);
Files.write(Paths.get(targetPath), content.getBytes(StandardCharsets.UTF_8));
另外数据库连接串也得加characterEncoding=utf8,尤其是MySQL未指定字符集时,getColumns返回的REMARKS字段经常在读取时就变成了乱码,这时候后面加什么编码转换都没用,必须在源头解决好。
5.3 主键策略选择错误
MyBatis-Plus有多种主键策略:AUTO(数据库自增)、ASSIGN_ID(雪花算法)、INPUT(手动输入)。代码生成器默认按数据库自增处理,但如果表用的不是自增主键,生成完之后插入数据就特别容易出问题。
我的解决思路是给元数据层加一个pkStrategy配置项,由使用者来选。同时在读取表结构时尝试自动判断:如果主键字段是bigint且表不在自增白名单里,默认推荐ASSIGN_ID;如果是int且表结构里带AUTO_INCREMENT,就用AUTO;如果是字符串类型varchar,则用ASSIGN_UUID。这样生成的entity注解就不会出现主键类型和数据库不匹配的情况。
5.4 生成的文件不知道放哪了
如果配置的目标根目录写得不对,生成器会按错误路径创建一堆文件,看起来整个工程都乱了。我的建议是,界面上增加一个“生成目录预览”,在选择好了模块名、业务名、包名之后,就先把最终输出路径算出来展示给用户确认,点了确认才真正写文件。
此外,文件写出前先做路径白名单校验,不允许越过设定的根目录。有一次我误将${moduleName}配置成了绝对路径/tmp,生成的代码差点把系统临时目录塞爆,加了白名单后这种低级错误再也没出现过。
5.5 模板报错信息不直观
FreeMarker的报错信息有时候确实让人摸不着头脑,比如Error executing FreeMarker template,然后跟着一大段不知道指向哪个文件的堆栈。我在渲染流程里做了一层异常包装,捕获FreeMarker异常后提取模板名称和出错行号,重新组织成更易读的格式:
java复制try {
template.process(dataModel, writer);
} catch (TemplateException e) {
String errorLine = e.getTemplate().getName() + ":" + e.getLineNumber();
throw new GenException("模板渲染失败,位置:" + errorLine + ",原因:" + e.getMessage(), e);
}
这样排错就快多了,直接跳到对应模板的具体行数去查问题。实际开发中大部分模板错误都是空值没有处理好,比如遍历一个可能为null的列表忘了加!,FreeMarker会直接报错,而我把数据模型统一预置成空列表之后,这个报错类型基本绝迹了。
6. 实战案例与经验总结
6.1 从一张业务表到全套代码的完整流程
我这里把完整流程串一遍,让第一次接触代码生成器的读者心里有个底。假设现在需要开发一个“车辆信息管理”模块,数据库表是vehicle_info,字段有id(主键)、plate_no(车牌号)、brand(品牌)、owner_name(车主姓名)、purchase_date(购买日期)、remark(备注)、create_time(创建时间)、update_time(修改时间)、deleted(逻辑删除)。
执行生成后,得到的产物包括:
VehicleInfo.java(entity):带@TableName注解,主键字段@TableId(type = IdType.AUTO),deleted字段带@TableLogic,createTime和updateTime带@TableField(fill = FieldFill.INSERT)和FieldFill.INSERT_UPDATE。VehicleInfoMapper.java(mapper接口):继承BaseMapper<VehicleInfo>。VehicleInfoService.java(服务接口):继承IService<VehicleInfo>,额外自动生成了分页查询方法签名。VehicleInfoServiceImpl.java(服务实现):继承ServiceImpl<VehicleInfoMapper, VehicleInfo>,实现了分页查询,并且封装了page参数和VehicleInfo查询实体的动态条件构造。VehicleInfoController.java(控制器):包含了获取列表、根据主键获取详情、新增、修改、删除五类RESTful接口。VehicleInfoMapper.xml(SQL映射文件):主要是动态SQL,支持根据车牌号模糊查询、根据购买日期范围查询。vehicleInfo.vue(前端Vue组件):列表页、表单弹窗(支持新增和编辑切换)、删除确认、分页组件。
这套代码从生成到跑通只需要两步:把文件复制到工程对应目录;在数据库执行一段菜单SQL。前后端联调几乎没有阻碍,因为字段命名和类型都统一,返回的数据结构也是标准的AjaxResult格式。
6.2 代码生成器的边界认知
用久了之后越来越清楚,代码生成器不是万能的,它擅长的是“结构固定、模式统一”的代码,面对复杂业务逻辑则作用有限。我自己划了几条边界,也帮你避开那些容易踩的坑:
- 擅长:CRUD接口、基础实体、简单查询条件、标准分页、表单页和列表页的骨架代码。
- 不擅长:复杂的审批流程、多表联合查询后DTO组装、有状态机的业务对象、复杂权限逻辑。
- 边界外:一切依赖运行时才确定的业务逻辑——比如一个字段的校验规则取决于另一个字段的当前值、一条数据的后续处理动作取决于它的历史状态,这类内容必须手写。
生成器真正提升效率的方式,是把80%确定性的代码自动搞定,把20%的精力省下来给复杂逻辑做精细设计。如果业务逻辑本身就很动态,硬套生成器反而会让代码更难维护。
6.3 团队落地时的注意事项
代码生成器从一个单机玩具变成团队工具,我总结了几条重要经验:
- 模板必须有版本管理。第一次把模板目录提交到Git之后,每次修改都要有commit记录,因为模板改坏了影响的是全团队。建议在模板文件头加上版本号注释,方便对应排查。
- 使用规范必须文档化。团队里新人用生成器最容易乱改参数,要么生成了代码不看直接提交,要么路径乱指导致文件四散。我整理了一份《代码生成器使用规范》,包括命名规则、生成目录约定、覆盖保护说明、提交前的自查清单。
- 定期收集反馈并优化模板。模板是活的,随着团队技术栈演进要持续调整。我们团队从前端vue2迁移vue3的时候,就是只改了前端模板并更新了生成器的前端代码片段,一次切换,全团队受益。
写在最后
花了两周时间把自研代码生成器从零到一搭起来,又用了一周时间把若依的生成流程完整梳理对照,最终沉淀下来的这套方案帮团队省下的时间,远远超过我写这篇文章的成本。回头来看,代码生成器这个工具的真正价值不只是省几行代码,它更像是一面镜子,逼着你把团队的代码规范、目录结构、命名规则一件件梳理清楚,这些规范本身带来的长期收益,比生成器本身还要大。
如果你也想做一套,我的建议是:先别一上来就写模板,花半天时间把已有的表和要生成的代码结构盘一遍,想清楚哪些是固定的、哪些是可变的,再动手。模板方面可以先从entity和mapper.xml入手,这两个是最简单也最能感受到效果的。跑通之后再逐步扩展到service和controller,前端部分等后端链路稳定了再补。
最后分享一个调试小技巧:在模板里加一段注释,把表名、生成时间、作者这些元数据打进去,这样后面任何人看到生成代码,都能快速追溯到是哪次生成、哪些内容是自动的。这个习惯帮我们排查过不少问题,希望你也能用上。
