☰
实体类驱动建表:MyBatis-Plus自动生成DDL与代码生成实践
2026/10/9 4:22:42 网站建设 项目流程

1. 项目思路拆解:实体类当“唯一事实来源”

1.1 传统流程里重复劳动有多痛

写了十年SQL,我原本以为自己最值钱的手艺就是建表和写CRUD。之前的项目节奏基本都是这样:需求评审完,先在建模工具里画出物理模型,确认字段类型、长度、默认值、注释,然后导出建表SQL,到数据库里执行。表建完,再打开IDE,新建实体类,把刚才表里那一堆字段重新翻译成Java类型,写注释、写注解。如果有DBA团队,中间还要把SQL脚本提交给DBA审核,DBA改一版再返回来,实体类也跟着改一版。

这套流程最大的问题不是慢,是同一份信息被反复手抄。表结构在建模工具里画一遍,建表脚本里写一遍,Java实体类里再写一遍,后面的VO、DTO可能再来两遍。手抄必然出错,而且出错方式非常隐蔽。我之前就踩过这种坑:某张表的字段在数据库里定义成了VARCHAR(255),实体类里却抄成了VARCHAR(2550),当时看着不明显,后来数据涨起来,某条数据刚好多出一个字符,线上插入直接报Data too long,查了大半天才发现是实体定义和真实表结构对不上。

算一笔时间账。一个中等业务模块大概10到20张表,每张表平均15个字段。一位熟练开发从画模型到写建表SQL,再到手写实体类,基本三个小时起步。中间要是需求改两轮,表结构跟着改,这个时间还得翻倍。十年下来,这种机械重复劳动消耗掉的时间非常可观,而且没有一点技术含量。所以这个项目换新架构时,我给自己定了一条原则:建表SQL和实体类,能交给工具生成的,绝对不动手敲。

1.2 两条自动化路线:实体出DDL,表结构出实体

这个项目里我采用的思路,核心是让Java实体类成为表结构的“唯一事实来源”。数据库表和Java领域模型,本质上描述的是同一组业务数据,只不过一个用SQL的语法表达,一个用Java的语法表达。信息既然是同一份,就没有理由在两个地方各写一遍。只要实体类定义是权威的、完整的,表结构就可以完全由它推导出来。

围绕这条原则,整个流程拆成两个自动化的方向。第一个方向是正向生成:新模块设计阶段,先写Java实体类,用MyBatis-Plus的注解把表名、列名、主键策略、字段映射这些元数据全部标注清楚,然后通过一小段工具代码或者IDE插件,直接从实体类反射出CREATE TABLE语句。新表上线连DDL都不用亲手写,适合从零开始的新业务。

第二个方向是反向生成:如果项目里已经有一批遗留表,或者DBA团队已经把物理模型定好了,那就可以用MyBatis-Plus的AutoGenerator代码生成器,根据数据库里的表结构直接生成实体类、Mapper、Service、Controller。这个方向适合老系统改造、新项目接入存量表。

两个方向配合起来,“从建表到实体”这件事就真正做到了零手写。这里没有任何黑科技,MyBatis-Plus本身提供了完整的代码生成能力,注解体系也很完善,我要做的只是把这些现成能力串成一条自动化的链路,再补一个自己用的DDL生成小工具。

2. 正向生成:让实体类自己产出建表SQL

2.1 实体类上的注解要标到什么程度

先看一个标准的实体类应该长什么样。MyBatis-Plus里最常用的三个注解是@TableName、@TableId和@TableField。@TableName标在类上,指定表名;@TableId标在主键字段上,说明主键策略;@TableField标在普通字段上,可以指定列名、控制字段是否存在、配合自动填充等等。

@TableName("t_order") public class Order { @TableId(value = "id", type = IdType.AUTO) private Long id; @TableField("order_no") private String orderNo; private String customerName; @TableField("total_amount") private BigDecimal totalAmount; @TableField(exist = false) private String unStoredField; @TableLogic private Integer deleted; }

注解标注的程度直接决定DDL生成的质量,有四个细节在实际项目中必须注意。第一,@TableField(exist = false)的字段在生成DDL时必须跳过,这类字段一般是关联查询的临时字段或者冗余展示字段,数据库里根本没有对应列。第二,主键类型一定要写清楚,IdType.AUTO对应MySQL的自增列,IdType.ASSIGN_ID是雪花算法主键,数据库列定义成BIGINT就可以,不需要自增。第三,逻辑删除字段用@TableLogic标注,字段类型建议用Integer,生成DDL时给个默认值0,避免插入时出现NULL。第四,乐观锁字段用@Version标注,类型建议Integer,也建议加默认值0。

MyBatis-Plus的注解体系本身就是完整的表元数据描述,反射能拿到Java类型和字段名,再结合注解信息,就能完整还原一张表的物理结构。

2.2 Java类型到MySQL类型的映射规则

实体字段是Java类型,建表SQL需要的是数据库类型,中间必须要有一张清晰的映射表。默认映射规则要尽量贴近常见业务,同时留出覆盖的余地。下面这张表是我在项目里实际使用的映射,基于MySQL 8.0:

Java类型MySQL类型说明
StringVARCHAR(255)长文本字段手动覆盖为TEXT或LONGTEXT
LongBIGINT主键、雪花ID、时间戳均可用
IntegerINT状态、数量等整型字段
BooleanTINYINT(1)MySQL中布尔就是TINYINT(1)
BigDecimalDECIMAL(18,2)金额字段,忽略浮点误差
LocalDateTimeDATETIME比TIMESTAMP更推荐,后面说原因
LocalDateDATE纯日期
LocalTimeTIME纯时间
DoubleDOUBLE浮点场景,注意精度问题
FloatFLOAT浮点场景
byte[]BLOB二进制内容

这里有几个选择是有考量的。日期时间类型我统一用DATETIME,不用TIMESTAMP。原因一是MySQL的TIMESTAMP有2038年问题,二是在时区配置不一致的环境里容易产生错乱。DATETIME不涉及时区转换,读写更可控。Decimal金额字段不能用Double或Float,二进制浮点数存在精度误差,账算平这种事在业务里是事故级别的问题。String默认VARCHAR(255)覆盖80%以上的字段,剩余的长文本、枚举文本靠columnDefinition单独覆盖。

覆盖机制是这样设计的:@TableField里有一个columnDefinition属性,可以写完整的列定义。我在工具里约定,一旦这个属性有值,就直接用它替代自动映射出来的数据类型,不再做默认转换。这样既保证了默认规则的简洁,也给特殊字段留了灵活的出口。

2.3 一个可用的DDL生成器核心代码

这个DDL生成器本质上是反射加字符串拼接,核心代码非常短。下面这段代码基于Spring Boot和MyBatis-Plus环境,可以直接放到项目工具包里面:

package com.example.ddl; import com.baomidou.mybatisplus.annotation.*; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.ArrayList; import java.util.List; public class DdlGenerator { public static String generateCreateTableSql(Class<?> entityClass) { TableName tableName = entityClass.getAnnotation(TableName.class); String table = tableNameValue(entityClass, tableName); List<String> columnDefs = new ArrayList<>(); String primaryKeyDef = null; String charset = "DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"; for (Field field : entityClass.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) { continue; } TableField tableField = field.getAnnotation(TableField.class); if (tableField != null && !tableField.exist()) { continue; } String columnName = camelToUnderscore(field.getName()); if (tableField != null && !tableField.value().isEmpty()) { columnName = tableField.value(); } TableId tableId = field.getAnnotation(TableId.class); if (tableId != null) { String colType = mapJavaTypeToMysql(field.getType()); StringBuilder idDef = new StringBuilder("`" + columnName + "` " + colType); if (tableId.type() == IdType.AUTO) { idDef.append(" AUTO_INCREMENT"); } primaryKeyDef = idDef.toString(); } else { String colType = mapJavaTypeToMysql(field.getType()); columnDefs.add(String.format("`%s` %s NOT NULL COMMENT '%s'", columnName, colType, field.getName())); } } if (primaryKeyDef != null) { columnDefs.add(0, primaryKeyDef + " COMMENT '主键'"); } StringBuilder sql = new StringBuilder(); sql.append("CREATE TABLE IF NOT EXISTS `").append(table).append("` (\n"); sql.append(String.join(",\n", columnDefs)); if (primaryKeyDef != null) { sql.append(",\n PRIMARY KEY (`id`)"); } sql.append("\n) ENGINE=InnoDB ").append(charset).append(";\n"); return sql.toString(); } private static String tableNameValue(Class<?> entityClass, TableName tableName) { if (tableName != null && !tableName.value().isEmpty()) { return tableName.value(); } return camelToUnderscore(entityClass.getSimpleName()); } private static String mapJavaTypeToMysql(Class<?> javaType) { if (javaType == String.class) return "VARCHAR(255)"; if (javaType == Long.class) return "BIGINT"; if (javaType == Integer.class) return "INT"; if (javaType == Boolean.class) return "TINYINT(1)"; if (javaType == BigDecimal.class) return "DECIMAL(18,2)"; if (javaType == LocalDateTime.class) return "DATETIME"; if (javaType == LocalDate.class) return "DATE"; if (javaType == LocalTime.class) return "TIME"; if (javaType == Double.class) return "DOUBLE"; if (javaType == Float.class) return "FLOAT"; if (javaType == byte[].class) return "BLOB"; return "VARCHAR(255)"; } private static String camelToUnderscore(String name) { return name.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); } public static void main(String[] args) { System.out.println(generateCreateTableSql(Order.class)); } }

这段代码把主链路跑通了:类名转表名、字段名转列名、Java类型转MySQL类型、主键识别、exist=false跳过。实际使用时可以把它封装成一个Spring Bean,放在独立模块里,或者做成本地命令行工具,在启动时输出SQL文件。

执行DDL时有两种路线。一种是工具生成.sql文件,交给人审阅后再执行;另一种是在工具里注入一个DataSource,启动阶段直接执行。我更推荐第一种。建表SQL属于生产环境的重大变更,全自动执行风险太高。工具生成初稿,人工确认二次修改,这个节奏最稳。

2.4 主键、注释、默认值的处理细节

主键策略值得单独说。MyBatis-Plus的IdType有AUTO、INPUT、ASSIGN_ID、ASSIGN_UUID几种。AUTO依赖数据库自增,生成的DDL里列必须有AUTO_INCREMENT;ASSIGN_ID是雪花ID,由应用侧生成,数据库只要BIGINT字段即可。如果实体类没有加@TableId注解,工具默认把字段映射为BIGINT,并在表末尾自动补上PRIMARY KEY。主键字段名建议统一用id,这样工具生成的DDL里PRIMARY KEY (id)才不会标错列。

注释这块,工具默认把Java字段名当COMMENT,生产环境肯定不行。我在实际项目里做了一层扩展:读Swagger的@Schema注解或者自定义的@Comment注解,优先取描述信息作为列注释;两个都没有再回退到字段名。表结构注释是长期可维护性的关键,裸表过几年根本没人记得字段含义,维护成本极高。

默认值处理是容易被忽略的细节。状态字段约定默认0或1,逻辑删除字段默认0,创建时间字段默认CURRENT_TIMESTAMP。这些都可以通过columnDefinition直接写完整定义:

@TableField(columnDefinition = "DATETIME DEFAULT CURRENT_TIMESTAMP") private LocalDateTime createTime; @TableField(columnDefinition = "INT DEFAULT 0") private Integer status;

这样生成的SQL就完整了。插入数据时不填这些字段,数据库会自动落默认值,应用侧代码也能少几行赋值逻辑。

3. 反向生成:遗留表结构到实体类的一键方案

3.1 FastAutoGenerator最小配置

正向生成解决新表,但真实项目里总有大量存量表。这些表结构是历史遗留的,建表SQL可能写了七八年,字段命名风格各异,靠手写实体类去匹配纯属折磨。这时候最省力的做法是反向生成,MyBatis-Plus官方提供的AutoGenerator新版叫FastAutoGenerator,配置非常简单:

import com.baomidou.mybatisplus.generator.FastAutoGenerator; public class Generator { public static void main(String[] args) { FastAutoGenerator.create( "jdbc:mysql://localhost:3306/biz?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai", "root", "password") .globalConfig(builder -> builder .author("yourname") .outputDir("/tmp/mp-generator")) .packageConfig(builder -> builder .parent("com.example.biz")) .strategyConfig(builder -> builder .addInclude("t_user", "t_order")) .execute(); } }

执行完之后,指定目录下会生成实体类、Mapper接口、XML文件、Service接口和ServiceImpl实现。实体类自动带@TableName、@TableId、@TableField注解,字段注释直接从数据库列的COMMENT读取,字段类型映射由官方维护,基本不会出错。这套能力大幅压缩了老表接入的工期。

3.2 配置项里最容易踩的细节

FastAutoGenerator的坑往往出在细节配置上。我实际总结下来,下面这些配置项每个项目都要显式设置:

.strategyConfig(builder -> builder .addInclude("t_user", "t_order") .entityBuilder() .enableLombok() .logicDeleteColumnName("deleted") .versionColumnName("version") .enableTableFieldAnnotation() .naming(NamingStrategy.underline_to_camel))

enableLombok生成@Getter/@Setter,省掉一堆样板方法。enableTableFieldAnnotation强制每个字段都生成@TableField注解,这一步非常关键,因为后面再用正向生成工具推DDL时,依赖的就是这些注解。逻辑删除字段和乐观锁字段直接在配置里声明,生成的实体类就会自动带上@TableLogic和@Version注解。

命名策略要特别小心。数据库表名大多是下划线风格,Java规范是驼峰,underline_to_camel是默认且推荐的行为。但如果你遇到的表名是大小写混用的非标准风格,NamingStrategy要改成no_change,否则生成出来的实体类名可能跟真实表名对不上,运行时直接报找不到表。

还有一个版本兼容问题。MySQL 5.7和8.0的驱动、连接串有差异。连接8.0的库时,连接串建议加上serverTimezone=Asia/Shanghai,否则生成时间字段时可能报错或得到错误的时区。驱动版本最好和数据库大版本对齐,别拿5.7的驱动连8.0的库,就算能连上,元数据读取也可能出现莫名其妙的问题。

4. 实操中的坑与排查实录

4.1 类型映射不合理的三类场景

正向生成自动映射出来的类型,至少有三类场景需要人工介入。

第一类是类型映射结果不符合业务语义。String默认是VARCHAR(255),但邮箱、手机号、URL这些字段实际长度可能只要64或128,用255既不精确又浪费索引空间;描述、备注类字段内容可能很长,VARCHAR(255)装不下,需要手动改成TEXT。针对这些情况,我建议在实体字段上用columnDefinition显式覆盖,不要全量接受默认值。

第二类是类型映射导致索引失效或报错。MySQL InnoDB下,单列索引最大长度是3072字节(8.0版本),utf8mb4字符集下每个字符最多4字节,所以VARCHAR(768)以上建立索引就可能超限。自动生成工具默认给String映射VARCHAR(255),如果某个字段被加上了索引,单个字段问题不大;但要是你手动把长度改成了1000还想建索引,就会触发索引长度限制报错。大文本字段最稳妥的做法是用TEXT,要么不建索引,要么使用前缀索引,要么冗余一个短字段用于查询。

第三类是日期时间字段缺少默认值。LocalDateTime映射成DATETIME没有问题,但创建时间、更新时间这些字段,自动生成只会得到普通DATETIME列,不会自动加DEFAULT CURRENT_TIMESTAMP。需要手动用columnDefinition覆盖:创建时间加DEFAULT CURRENT_TIMESTAMP,更新时间加DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,否则应用侧插入时还得手动赋值,漏了就是NULL。

4.2 执行DDL时的三类经典报错

自动生成的DDL在真实环境执行时,我遇到过的报错基本分三类。

第一类是关键字冲突。表名或列名撞上MySQL保留字,比如order、group、desc、status、rank这些。工具生成SQL时统一用反引号包裹表名和列名,能规避掉大部分问题。但表名本身叫order的话,即使建表时用了反引号,后续MyBatis-Plus自动生成的查询SQL也可能出现冲突。最稳的解法是建表时就把表名改掉,带上业务前缀,比如t_order、biz_user。

第二类是字符集不一致。工具生成的SQL里显式指定了utf8mb4和utf8mb4_general_ci,但如果数据库实例默认字符集是latin1,历史表迁移过来就很容易乱码。注意utf8mb4和utf8是两个不同的字符集,MySQL里的utf8实际只是utf8mb3,存不了emoji和部分生僻汉字,新表一律用utf8mb4。建库的时候也建议统一把库级字符集设成utf8mb4。

第三类是主键自增配置错位。实体类主键标注IdType.AUTO,但表结构没有AUTO_INCREMENT,插入数据时就会报错:

ERROR: Field 'id' doesn't have a default value

反过来,表结构有自增但实体类标注ASSIGN_ID,MyBatis-Plus会试图在应用侧生成主键,导致双份主键逻辑冲突。排查方法很简单,两边各看一遍:实体里看@TableId注解,数据库里执行SHOW CREATE TABLE看列定义。

4.3 表结构演进:增量同步怎么做

自动生成解决的是“建表”这个一次性动作,但表结构的演进是持续发生的。实体类加了两个字段,数据库里没有对应列,总不能每次都把表DROP掉重建,生产环境没人敢这么干。

这个场景我目前的方案是引入Flyway或Liquibase做版本化迁移,迁移脚本由人编写,不完全追求自动化。实体类变更之后,用DDL生成工具产出一份完整的CREATE TABLE脚本,放到两个环境对比,人工把差异整理成ALTER TABLE语句。对比工具可以用Navicat的表结构同步,或者在测试库跑生成,再和线上库的SHOW CREATE TABLE做diff。

为了让这个增量过程更顺畅,我坚持在实体类上改字段定义,而不是直接改数据库,然后定期用对比工具同步到测试环境。数据库变更记录以实体类上的注解为源头,就不会出现改了一边忘了一边的情况。配合Flyway的版本化管理,每次变更都有记录,回滚也方便。

4.4 团队里必须定的几条规范

这套自动化流程要稳定运转,团队必须定规则。规矩不是教条,是为了让工具的输出保持可预期。

第一条,注释必须写。实体类属性至少有一句中文注释,工具生成的列COMMENT直接取自注释,没有注释的列在评审时直接打回。数据库表的可读性,首先是靠COMMENT撑着。

第二条,主键策略必须显式声明。每个实体类都要有明确的@TableId注解,哪怕是默认的ASSIGN_ID也要写出来。依赖默认策略会导致生成的DDL和实际策略不一致。

第三条,禁止在实体类里塞业务方法。实体越纯净,反射出来的DDL就越干净。字段上大量堆砌展示注解和业务注解,反射时要排除的干扰项就越多,工具代码会越来越复杂。

第四条,生成SQL必须人工Review。工具只能保证生成结果符合Java定义,不能保证符合业务设计。字段长度是否合理、索引是否够用、默认值是否正确,这些判断必须由人来完成。

5. 收益、边界和一点延伸

5.1 省掉的时间和更重要的正确率

这个项目实际跑下来,新增20张表左右的子系统,从实体类写完到建表SQL评审通过,大概省了三分之二的工时。以前要画模型、手写DDL、再手抄实体,现在只需要在实体类上把注解和注释写清楚,一条命令生成SQL,评审后直接执行。

比节省时间更重要的是正确率。字段名、字段类型、字段注释这三样东西,以前在两个文件里各出现一次,总有一方会滞后。现在源头只有一个实体类,表结构由它生成,只要生成规则稳定,表和实体定义不一致的问题就从根本上消失了。这个收益在长期维护中特别明显:需求变更时,改一个文件,另一个产物自动跟着变。

5.2 这套方案不适合什么

说实话,这个方案不是银弹。它解决的是“实体类定义到建表DDL”这段重复劳动,但表结构设计本身还是要人来做。哪些字段应该有、主键用什么策略、要不要冗余、索引怎么设计,这些决策生成器替代不了。工具生成的是“符合Java类型的SQL”,不是“符合业务设计的SQL”。

复杂表结构的支持也比较有限。联合主键、复合索引、分区表、生成列、外键约束,这些在实体类上表达非常别扭。碰到这类表,我建议回归传统方式,手动写建表SQL,实体类再用反向生成去匹配。工具是服务人的,不是为了自动化而绑架人。

5.3 后续可以怎么扩展

这个思路继续延伸,有几个方向值得做。一个是把生成的工具做成Maven插件,编译时自动产出DDL文件到target目录,和CI流程集成,生成一次、审阅一次、归档一次。另一个是和Flyway集成,从实体类直接生成V版本迁移脚本,把开发和数据库变更串成一条链。还有一个方向是反向扩展,表结构定义好之后,不只生成Java实体,还能生成TypeScript类型定义和前端表单字段的元数据,前后端共用一套“唯一事实来源”。

我在实际使用中有一个习惯一直保留:每次生成SQL之后,把SQL脚本存到项目源码的db目录下,标注生成时间和对应实体类的commit号。以后有人问“这张表的字段什么时候加的、为什么加”,翻一下commit记录和SQL脚本就能对上号,比只依赖数据库元数据可靠得多。

最后再分享一个小技巧:如果用Lombok,反射拿字段时用getDeclaredFields(),直接拿到有效字段列表。不要用getMethods()去推字段,那会把父类的方法、IDE生成的合成方法都算进来,生成的DDL会莫名其妙多出几列。getDeclaredFields()配合注解反射,是目前最稳的组合。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询