拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从建表SQL到Java代码:代码生成器的全流程解析与避坑指南

从建表SQL到Java代码:代码生成器的全流程解析与避坑指南

简介:一款面向Java后端开发者的本地代码生成工具,可根据数据库表信息一键生成控制层、服务层、仓储层、实体类、映射器接口及映射文件的基础增删改查代码,帮助开发者摆脱重复手写数据访问层的繁琐工作。资源以压缩包形式提供,共25个文件,包括18个Java源码/模板文件、2个映射配置文件、1个数据库连接配置、1个示例建表脚本、1个可执行工具、1个启动脚本以及1份详细使用说明,整体约24.51MB。目前已有1934人学习/下载。使用时只需按说明修改配置中的绝对路径、数据库连接信息及目标表名,再运行启动脚本,即可在指定目录中得到带接口注释、实体映射注释和基础增删改查逻辑的代码文件;这些代码可直接复制到项目里调整,大部分增删改查场景都能快速落地。配套的建表脚本和使用说明让整个流程易于验证,适合有一定基础的Java开发者用于项目初始阶段或表结构变更后的代码同步。

1. 根据数据库SQL生成Java代码的代码生成器:一次解析,几十张表一起出码

数据库设计评审刚过,建表 SQL 发到群里,后端下半夜就得开始一项纯体力劳动:照着一个 bigint、一个 varchar(64) 把 Entity 敲出来,再复制进 Mapper XML 写 resultMap,字段一多眼睛就花,少写一个字段要等联调才发现。根据数据库 SQL 生成 Java 代码的代码生成器,就是为这个场景准备的:把 CREATE TABLE 解析成结构化元数据,再用模板批量产出 Entity、Mapper、XML、Service,几十张表的重复代码几秒生成完。它适合手里有建表脚本、或者能连上测试库的 Java 后端团队,尤其适合 Spring Boot + MyBatis 这类模板化很强的组合。这篇文章把解析、类型映射、模板渲染、落盘完整走一遍,最后是五条血泪踩坑。

2. 解析建表SQL这一步:JSqlParser 和 JDBC 两条路线怎么选

生成器的第一步永远是拿表结构。这一步有两条路线:直接连数据库用 JDBC 的 DatabaseMetaData 接口捞字段信息;或者拿一个 schema.sql,用 SQL 解析库把 CREATE TABLE 拆开。两条路线的差异不只是代码写法不同,而是整个生成器的运行环境和依赖都不一样。

2.1 能连数据库就走 JDBC,只有SQL文件才去硬解析

我一般会优先连库,因为数据库里存的信息最全:列注释、主键、是否自增、默认值,一个接口全都有。代价是生成环境必须能连上数据库,账号得有 information_schema 的读权限。很多公司数据库权限管得严,生产库肯定不能连,那就退回读 SQL 文件的路线。

DatabaseMetaData metaData = connection.getMetaData(); ResultSet tables = metaData.getTables(null, null, "%", new String[]{"TABLE"}); while (tables.next()) { String tableName = tables.getString("TABLE_NAME"); ResultSet columns = metaData.getColumns(null, null, tableName, null); List<ColumnMeta> columnMetas = new ArrayList<>(); while (columns.next()) { ColumnMeta meta = new ColumnMeta(); meta.setColumnName(columns.getString("COLUMN_NAME")); meta.setSqlType(columns.getString("TYPE_NAME")); meta.setComment(columns.getString("REMARKS")); meta.setNullable(columns.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls); columnMetas.add(meta); } ResultSet pk = metaData.getPrimaryKeys(null, null, tableName); while (pk.next()) { String pkColumn = pk.getString("COLUMN_NAME"); columnMetas.stream() .filter(c -> c.getColumnName().equalsIgnoreCase(pkColumn)) .forEach(c -> c.setPrimaryKey(true)); } // 到这里,一张表的字段列表、注释、主键就都拿到了 }

这段代码里最容易被忽略的是连接串参数:MySQL 要带上 useInformationSchema=true 和 characterEncoding=utf8,否则 REMARKS 字段返回 null,中文注释在生成结果里全变成 null。getPrimaryKeys 返回的可能是复合主键,循环里做 equalsIgnoreCase 匹配就行;如果一张表没有主键,生成器后续做按主键更新的方法时就要特殊处理。

JDBC 方式还有个隐藏好处:数据库对类型名和大小写的处理已经帮你做过了,比如 MySQL 的 int 在 TYPE_NAME 里就是 INT,不会出现 varchar(64) 带括号的情况。缺点也很明显:它要求表真的存在于库里。如果你们是拿着 SQL 做评审、库还没建,或者生成器要跑在 CI 环境里不想依赖外部服务,离线解析 SQL 文件就更合适。

2.2 用 JSqlParser 把CREATE TABLE拆成表结构

离线解析在 Java 生态里最省事的库是 JSqlParser,Maven 坐标是 net.sf.jsqlparser:jsqlparser,专门把 SQL 字符串变成 Java 对象。先跑一个最小解析看它长什么样:

import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; String createSql = "CREATE TABLE sys_user (\n" + " id BIGINT AUTO_INCREMENT COMMENT '主键ID',\n" + " user_name VARCHAR(64) NOT NULL COMMENT '用户名',\n" + " PRIMARY KEY (id)\n" + ") COMMENT='用户表'"; CreateTable ct = (CreateTable) CCJSqlParserUtil.parse(createSql); System.out.println(ct.getTable().getName()); // sys_user System.out.println(ct.getColumnDefinitions().size()); // 2列 for (ColumnDefinition cd : ct.getColumnDefinitions()) { System.out.println(cd.getColumnName()); // id / user_name System.out.println(cd.getColDataType().getDataType()); // BIGINT / VARCHAR System.out.println(cd.getColumnSpecs()); // [AUTO_INCREMENT, COMMENT, '主键ID'] }

两个关键说明。第一,CCJSqlParserUtil.parse 一次只解析一条语句,schema.sql 里如果塞了几十条 CREATE TABLE,要自己按分号拆分,或者逐条读取单独 parse,别直接丢整份文件进去,否则会在第一条语句结束后的位置报错。第二,COMMENT 在 columnSpecs 里的形态随版本变化:有的版本里 COMMENT 和 '主键ID' 是数组里相邻的两个元素,有的版本已经把它收拢成字符串。拿到数组先打印出来看一眼,再写抽取逻辑,别照着网上的旧帖子写死下标。

2.3 注释、主键、自增:解析完怎么组装成元数据模型

解析完得到的是零散字符串,离能用还差一步:把字段、注释、主键、自增这些信息统一装进元数据模型。这是整个生成器的数据地基,后面模板渲染只认这个模型,不认 SQL。

public class TableMeta { private String rawTableName; // 保留原始大小写,生成 XML 时要用 private String comment; // 表注释 private List<ColumnMeta> columns = new ArrayList<>(); } public class ColumnMeta { private String columnName; // 原始列名 private String sqlType; // 原始 SQL 类型 private String comment; // 列注释 private boolean primaryKey; private boolean autoIncrement; private boolean nullable; }

装配代码里最麻烦的是表级注释和主键。MySQL 的 CREATE TABLE 末尾那段 COMMENT='用户表',JSqlParser 有的版本解析到 TableOptions 里,有的版本要自己用正则兜底。主键也有两种写法:列定义里直接带 PRIMARY KEY,或者表级 PRIMARY KEY (id) 单独列一行。

// 表级注释:正则兜底,处理 COMMENT='用户表' Matcher m = Pattern.compile("COMMENT\\s*=\\s*'([^']*)'", Pattern.CASE_INSENSITIVE) .matcher(createSql); if (m.find()) { tableMeta.setComment(m.group(1)); } // 列级注释和自增、主键 for (ColumnDefinition cd : ct.getColumnDefinitions()) { ColumnMeta meta = new ColumnMeta(); meta.setColumnName(cd.getColumnName()); meta.setSqlType(cd.getColDataType().getDataType()); meta.setComment(extractComment(cd.getColumnSpecs())); meta.setAutoIncrement(cd.getColumnSpecs().stream() .anyMatch(s -> s.toUpperCase().contains("AUTO_INCREMENT"))); boolean pk = cd.getColumnSpecs().stream() .anyMatch(s -> s.toUpperCase().contains("PRIMARY KEY")); if (!pk && primaryKeyColumns.contains(cd.getColumnName())) { pk = true; // 表级 PRIMARY KEY (id) 命中的列 } meta.setPrimaryKey(pk); }

表级主键的列名集合,可以从 JSqlParser 的主键 API 拿,也可以直接用正则 PRIMARY KEY\s*(([^)]+)) 抠。我的经验是正则更稳,因为不依赖具体版本的 API。还有一点:rawTableName 必须从头保留到渲染结束,生成 Mapper XML 时用原始大小写,中途转驼峰会丢掉大小写信息,这个问题在第 5.4 节还会踩一次。

3. SQL类型到Java类型:映射表和命名策略是生成器的灵魂

元数据模型建好后,下一道工序是翻译:把数据库类型翻译成 Java 类型,把下划线列名翻译成驼峰属性名。这一步做得好不好,直接决定生成出来的代码能不能过编译、能不能直接进业务开发。

3.1 SQL类型到Java类型的映射表:一张要不断扩展的表

映射表是整个生成器里最需要维护的部分,因为每个公司用的数据库方言不一样。先给出 MySQL 最常见的一套映射,这是我实际项目里的默认值:

SQL 类型(MySQL)Java 类型是否需要 import
varchar / char / text / longtextString否
int / integer / tinyint / smallintInteger否
bigintLong否
decimal / numericBigDecimaljava.math.BigDecimal
float / doubleFloat / Double否
bit / booleanBoolean否
dateLocalDatejava.time.LocalDate
datetime / timestampLocalDateTimejava.time.LocalDateTime
timeLocalTimejava.time.LocalTime
blob / longblobbyte[]否

映射规则里有两个必须处理的变种。第一个是 tinyint(1):很多团队把它当布尔用,生成 Boolean 更符合业务直觉;但有的老表里 tinyint(1) 存的其实是 0/1/2,强行生成 Boolean 会让业务判断出现真空。我会在配置里放一个开关 tinyintAsBoolean,默认 false 生成 Integer,确认规范后需要时再打开。第二个是 int unsigned:MySQL 无符号整型的取值范围已经超过 Java 的 Integer,如果库里有这种列,建议映射到 Long,否则数据超过 21 亿时直接溢出。

public final class TypeMapping { private static final Map<String, String> MAP = new HashMap<>(); static { MAP.put("varchar", "String"); MAP.put("char", "String"); MAP.put("text", "String"); MAP.put("longtext", "String"); MAP.put("int", "Integer"); MAP.put("integer", "Integer"); MAP.put("tinyint", "Integer"); MAP.put("smallint", "Integer"); MAP.put("bigint", "Long"); MAP.put("decimal", "BigDecimal"); MAP.put("numeric", "BigDecimal"); MAP.put("float", "Float"); MAP.put("double", "Double"); MAP.put("bit", "Boolean"); MAP.put("date", "LocalDate"); MAP.put("datetime", "LocalDateTime"); MAP.put("timestamp", "LocalDateTime"); MAP.put("time", "LocalTime"); MAP.put("blob", "byte[]"); } public static String toJavaType(String sqlType, boolean tinyintAsBoolean) { String key = sqlType.toLowerCase(); if (tinyintAsBoolean && "tinyint".equals(key)) { return "Boolean"; } return MAP.getOrDefault(key, "String"); } }

映射方法入口收到的一定是干净的类型名,也就是解析阶段已经去掉括号的类型。这一步别偷懒,别在映射方法里做字符串替换,因为 varchar(64) 直接匹配不上 varchar。无法识别的类型回退到 String 并打 WARN 日志,这样比生成时抛异常好——但警告一定要打出来,避免一个 timestamp 被悄悄当成 String 用。

类型映射完还要同步算 import 列表。String、Integer、Long 在 java.lang 里不用引,BigDecimal、LocalDate、LocalDateTime、LocalTime 必须 import。我把这个逻辑放在 TableMeta 里:

public List<String> getImportList() { Set<String> imports = new TreeSet<>(); for (ColumnMeta c : columns) { switch (c.getJavaType()) { case "BigDecimal": imports.add("java.math.BigDecimal"); break; case "LocalDate": imports.add("java.time.LocalDate"); break; case "LocalDateTime": imports.add("java.time.LocalDateTime"); break; case "LocalTime": imports.add("java.time.LocalTime"); break; } } return new ArrayList<>(imports); }

这样模板里只需要 list importList 循环输出,不用在 .ftl 里摆弄条件判断,也避免出现同一个 import 被写两遍的问题。

3.2 下划线转驼峰:命名规则的三个边界

下划线转驼峰是生成 Entity 字段名时的重头戏,大多数人的第一版实现就是循环处理下划线,但实际会撞上三个边界。

第一个边界是列名本身已经是驼峰。有些历史表是用 orderId 这种命名建的,你的转换方法如果无条件把首字母大写,orderId 会变成 Orderid。所以要先判断字符串里有没有下划线,没有就只做首字母大小写调整:

public static String toCamel(String name, boolean firstUpper) { if (name == null || name.isEmpty()) { return name; } if (!name.contains("_")) { return firstUpper ? Character.toUpperCase(name.charAt(0)) + name.substring(1) : Character.toLowerCase(name.charAt(0)) + name.substring(1); } StringBuilder sb = new StringBuilder(); boolean upper = firstUpper; for (int i = 0; i < name.length(); i++) { char ch = name.charAt(i); if (ch == '_') { upper = true; } else { sb.append(upper ? Character.toUpperCase(ch) : Character.toLowerCase(ch)); upper = false; } } return sb.toString(); }

第二个边界是连续下划线。user__name 这种列名在数据质量差的表里不算罕见,按上面的算法会得到 userName,但中间那个双下划线直接消失,语义其实不对。我的建议是建表规范里直接禁止连续下划线,生成器侧遇到就打 WARN,别默默吞掉。

第三个边界是列名里的数字。order_2019 转出来是 order2019,这是合法 Java 标识符,不用处理。但 2019_order 这种数字开头的列名在 MySQL 里可以存在,生成 Java 字段名时必须以字母或下划线开头,遇到这种情况我选择直接报错,而不是自动加前缀——自动改名字会让业务代码和数据库列对不上,出了问题很难排查。

3.3 TableMeta与ColumnMeta的完整形态

前面散着定义了两个类,真正落地时还要加几个生成期辅助方法,让模板层保持零逻辑。模板里只需要取值,不需要做任何计算,这样出了问题能快速分清是数据错了还是模板错了。

public class TableMeta { private String rawTableName; private String comment; private List<ColumnMeta> columns = new ArrayList<>(); public String getEntityName() { String camel = toCamel(rawTableName, true); return camel.endsWith("Entity") ? camel : camel + "Entity"; } public String getMapperName() { return toCamel(rawTableName, true) + "Mapper"; } public ColumnMeta getPrimaryKey() { return columns.stream() .filter(ColumnMeta::isPrimaryKey) .findFirst().orElse(null); } }

getPrimaryKey() 返回 null 的情况要提前处理:没有主键的表,selectByPrimaryKey、updateByPrimaryKey 都没法生成。我的做法是在渲染前统一校验一遍,缺主键的表只生成 Entity,不生成 Mapper,同时把警告打到控制台。别让模板渲染到一半才报错,那时候你已经改不动数据了。Entity 后缀加不加也要做成配置项,有的团队喜欢直接叫 SysUser,有的要求加 Entity 后缀避免和其他业务类混淆。

4. 用Freemarker把元数据渲染成Entity和Mapper:最小可运行实现

元数据准备好后,生成器就进入了真正出代码的阶段。这个阶段核心是模板引擎,模板文件决定了产出代码的风格,也是整个生成器里最值得花时间打磨的部分。

4.1 为什么用模板引擎而不是StringBuilder拼代码

第一版生成器最容易写成 StringBuilder.append("public class ") + className + append(" {")……刚开始觉得挺爽,等你要给字段加 Javadoc、按条件生成 import、或者往 Mapper XML 里输出<where>标签时,字符串拼接的缩进、换行、转义会让代码变得完全不可读。模板引擎把这段逻辑反过来:代码骨架是模板文件,生成器只负责填数据。

Freemarker 和 Velocity 都能干这事。我选 Freemarker,理由很实际:Spring Boot 的 starter 就是它,团队里大多数人熟。模板里能用 <#list> 遍历字段,能写 <#if> 做条件,已经覆盖生成器 99% 的场景。模板放 resources/templates 目录后,改模板不用重新编译生成器,这个体验比改 Java 代码里的字符串强太多。

4.2 Entity模板和Mapper模板:一份能直接进项目的骨架

entity.ftl 负责生成实体类,模板里只做取值和循环:

package ${pkg}; <#list importList as imp> import ${imp}; </#list> /** * ${table.comment} * 表名:${table.rawTableName} */ @Data public class ${table.entityName} { <#list table.columns as col> /** * ${col.comment} */ private ${col.javaType} ${col.javaName}; </#list> }

mapper.ftl 负责生成 Mapper 接口,里面是数据库增删改查的最小集:

package ${pkg}; import ${entityPkg}.${table.entityName}; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @Mapper public interface ${table.mapperName} { int insert(${table.entityName} record); int updateByPrimaryKey(${table.entityName} record); ${table.entityName} selectByPrimaryKey(${table.primaryKey.javaType} ${table.primaryKey.javaName}); int deleteByPrimaryKey(${table.primaryKey.javaType} ${table.primaryKey.javaName}); }

mapper.ftl 里 selectByPrimaryKey 的参数类型直接取主键列的 javaType,所以要求 TableMeta 在渲染前算好 primaryKey 字段;如果主键为 null,Freemarker 会在取值时报错。Mapper 接口里将来写参数默认值、分页这些,全走 #{} 占位符,天然避开 sql 注入的问题,这个习惯要从模板里固化下来,而不是等业务代码里再规范。

Mapper XML 的模板同样重要,resultMap 和列清单是重头戏:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="${pkg}.${table.mapperName}"> <resultMap id="BaseResultMap" type="${entityPkg}.${table.entityName}"> <#list table.columns as col> <result column="${col.columnName}" property="${col.javaName}"/> </#list> </resultMap> <sql id="Base_Column_List"> <#list table.columns as col> ${col.columnName}<#if col_has_next>,</#if> </#list> </sql> </mapper>

这里最能看出 2.3 节强调保留原始 columnName 的价值:result 标签的 column 用的是数据库原始列名,property 用的是驼峰属性名,两套名字在同一个文件里共存,一旦中途丢了一个,生成的就是一个看起来正常但运行时完全对不上的 XML。

4.3 生成器主流程:从SQL文件到落盘

模板就位后,主流程就是一个循环加一次渲染,但配置项的设计决定了生成器能不能适应不同项目。我习惯用一个 generator.yml 把路径和开关集中起来:

sqlFile: ./schema.sql templateDir: ./src/main/resources/templates tablePrefix: sys_ entityPackage: com.example.demo.entity mapperPackage: com.example.demo.mapper mapperXmlDir: ./src/main/resources/mapper outputDir: ./target/generated-sources/java tinyintAsBoolean: false

主流程代码:

public class CodeGenerator { public static void main(String[] args) throws Exception { GeneratorConfig config = GeneratorConfig.load("generator.yml"); List<TableMeta> tables = SqlMetaLoader.load(new File(config.getSqlFile())); for (TableMeta table : tables) { if (config.getTablePrefix() != null && !config.getTablePrefix().isEmpty()) { table.setRawTableName(table.getRawTableName() .replaceFirst("^" + config.getTablePrefix(), "")); } } FreemarkerEngine engine = new FreemarkerEngine(config.getTemplateDir()); for (TableMeta table : tables) { if (table.getPrimaryKey() == null) { System.err.println("WARN: " + table.getRawTableName() + " 没有主键,跳过Mapper生成"); continue; } engine.render("entity.ftl", table, config.getOutputDir() + "/" + config.getEntityPackage().replace('.', '/') + "/" + table.getEntityName() + ".java"); engine.render("mapper.ftl", table, config.getOutputDir() + "/" + config.getMapperPackage().replace('.', '/') + "/" + table.getMapperName() + ".java"); } } }

几个参数的坑要说清楚。tablePrefix 用来剥业务表前缀,sys_user 剥掉 sys_ 后生成 UserEntity,否则每个类都叫 TSysUser 很难受。剥前缀用的是正则 replaceFirst,注意前缀里的特殊字符要转义,比如 t_ 没问题,但 cms_ 里的下划线在正则里不是特殊字符,而任何带 . 或 * 的前缀都会出问题。渲染引擎封装时一定要设置 UTF-8 编码:

Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDirectoryForTemplateLoading(new File(templateDir)); cfg.setDefaultEncoding("UTF-8"); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);

这里 setDefaultEncoding("UTF-8") 是很多人第一次跑生成器就翻车的地方,Windows 下不设这一行,注释里的中文全变乱码。outputDir 指向 target/generated-sources/java 的好处是构建时不会混进 src 目录,配合 build-helper-maven-plugin 可以把生成代码直接编译进 classpath;但如果团队习惯直接改生成代码,就输出到业务源码目录,这个选择本质上决定了生成器是工具还是一次性脚手架。

5. 代码生成器的避坑记录:5个让生成结果作废的高频问题

生成器跑通容易,但要保证生成结果能在真实项目里活下来,下面这五个问题几乎每个团队都会撞上至少一个。每一条我都按现象、原因、解决三个步骤记录。

5.1 关键字列名让Mapper XML直接编译失败

现象:生成完一跑 MyBatis,报 BuilderException,SQL 解析阶段就挂掉,定位到一条 order 字段上的查询。

原因:建表的人用了 order、desc、group、index 这类 MySQL 保留字做列名,生成器在 resultMap、insert 列清单、where 条件里全裸写列名,数据库解析到保留字直接语法错误。

解决:维护一份保留字清单,命中就用反引号包起来,order、desc这样输出。注意 insert 的列名、update 的 set 片段、where 条件每一处都要处理,不能只处理 resultMap。保留字清单不用追求全,覆盖 order、desc、group、index、key、status 这些高频就够日常顶一阵。根本解法还是回到建表规范,禁止保留字做列名,生成器只是兜底。

5.2 自增主键和逻辑删除字段被当成普通字段

现象:insert 语句把自增主键 id 也插进去了,数据库报主键不可插入;逻辑删除字段 deleted 出现在查询条件里,把已删除的数据捞回来。

原因:解析阶段拿到了 autoIncrement 标记,但生成 insert SQL 时没过滤;逻辑删除字段完全没有识别机制。

解决:在 ColumnMeta 加 autoIncrement 和 logicalDelete 两个布尔标记。生成 insert 时跳过 autoIncrement 列,生成查询和删除语句时,如果表里有逻辑删除字段,自动拼上 deleted = 0 条件。逻辑删除字段名做成配置项,默认值为 deleted,表里有这个字段就自动启用,没有就不处理。

5.3 JDBC方式取不到注释,Javadoc全是null

现象:用 JDBC 方式生成,Entity 里所有字段注释清一色 null,生成出来的代码跟白板一样,可读性大打折扣。

原因:MySQL 的 getColumns() 返回的 REMARKS 字段需要驱动去 information_schema 里查,默认连接串下很多驱动版本直接不查,REMARKS 就是 null。

解决:JDBC URL 拼成 jdbc:mysql://host:3306/db?useInformationSchema=true&characterEncoding=utf8,两个参数缺一个都会出问题。如果加了参数还是 null,检查建表时到底有没有写 COMMENT,很多历史表只有列名没有注释,这是数据库规范问题,生成器解决不了。

5.4 表名大小写:Windows能跑,Linux连表都找不到

现象:本地 Windows 连 MySQL 生成并启动一切正常,提交到 Linux 服务器后,MyBatis 启动报 Table 'sys_user' doesn't exist。

原因:Windows 上 MySQL 默认 lower_case_table_names=1,表名大小写不敏感;Linux 默认是 0,大小写敏感。生成器如果不小心把表名转成了驼峰 SysUser,真实表名却是 sys_user,在 Linux 上就找不到。

解决:Mapper XML 里 table 名永远用原始大小写 sys_user,MyBatis-Plus 场景则在实体上加 @TableName("sys_user")。生成器内部从头到尾保留 rawTableName,中途任何位置都不要转驼峰替换掉原始值。这条跟进 2.3 节里强调的"原始表名留到最后"是同一个问题,Double Check 一下生成物里的表名到底是不是原样。

5.5 覆盖生成把手写代码全部冲掉

现象:改完代码重新生成一次,手写的加密逻辑、校验分支全没了,git diff 一片红,同事差点跟你拼命。

原因:生成器直接 FileWriter 覆盖目标文件,没有考虑已有文件里的手工改动,也没做备份。

解决:生成前读旧文件,生成后逐行 diff,默认只打印差异不落盘,加 --force 参数才真正覆盖;同时给模板引入代码保护带,这是让生成器从"一次性脚本"变成"可反复使用工具"的关键,做法放在下一章。

6. 给生成器加道后悔药:dry-run与代码保护带的落地技巧

6.1 先写临时目录再对比:生成前留一次反悔机会

我后来的做法是让生成器默认不直接写目标文件,而是先生成到临时目录,再和已存在的文件做逐行对比:

Path preview = Files.createTempDirectory("gen-preview"); String previewPath = preview.resolve(fileName).toString(); engine.render("entity.ftl", table, previewPath); List<String> oldLines = Files.exists(target) ? Files.readAllLines(target, StandardCharsets.UTF_8) : List.of(); List<String> newLines = Files.readAllLines(Path.of(previewPath), StandardCharsets.UTF_8); if (!oldLines.equals(newLines)) { System.out.println("diff: " + target); // 打印差异内容,只有加了 --force 才覆盖 }

这个习惯是被覆盖生成坑过两次之后养成的。生成器一旦能反复跑,数据库修改结构就是日常操作,每次 DDL 变更加上一句生成命令,先看差异再决定要不要合入,比直接覆盖安全一个量级。

6.2 代码保护带:让手工代码在重生成中存活

模板里给业务代码留两个标记,重生成时把旧文件标记之间的内容抠出来,插回新文件:

private static final String USER_CODE_START = "// @@USER_CODE_START@@"; private static final String USER_CODE_END = "// @@USER_CODE_END@@"; public static String preserveUserCode(String oldContent, String newContent) { String block = extract(oldContent, USER_CODE_START, USER_CODE_END); if (block == null || block.isEmpty()) { return newContent; } return newContent.replace( USER_CODE_START + "\n" + USER_CODE_END, block); }

这个技法看起来很原始,但比任何"智能合并"都可靠,MyBatis Generator 时代大家就靠它保护定制代码。我自己的教训是:生成器跑通只是开始,真正的工程化在生成之后的合并策略上。把生成器挂在 Maven 编译阶段之前,建表脚本一变,跑一次生成命令,diff 之后合入,这套流程我用了很久没出过大事。代码生成器解决的是重复劳动,但解决不了建表规范的债——注释写没写、保留字惹不惹事、主键有没有,全在源头。把源头管住,生成器就是个顺手的利器。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表