1. 项目概述与需求解析
做后端开发这些年,我几乎每天都在和 MySQL 建表语句、Java 实体类、MyBatis XML 这三样东西打交道。它们在本质上描述的是同一套数据模型,却要用三种完全不同的语法表达。字段一多,人的耐心就很快被耗尽。后来我花了一个周末,写了一个纯 HTML 工具,把 MySQL 的 CREATE TABLE 语句丢进去,就能自动生成对应的 Java 实体类和 MyBatis 语句。这篇文章就把这个工具的完整实现思路、关键代码和一些踩坑经验整理出来,给有同样痛点的朋友做个参考。
这类工具并不是什么新概念,很多 IDE 插件也带类似功能,但我更想要一个零依赖、双击就能用、还能方便改模板的小页面。它不是要替代正儿八经的代码生成器,而是解决日常开发里那些“一二十个字段的小表”的重复劳动。尤其是新项目启动、表结构频繁调整的时候,把 DDL 复制进去点一下,实体类和老四样 SQL 就出来了,再手动改一改业务逻辑,效率提升非常明显。
1.1 这个工具解决什么问题
先说痛点在哪儿。假设产品要加一张配置表 t_user_config,一共 30 个字段。手工写实体类,要把每个下划线字段转成驼峰,还要对照类型表把 varchar 映射成 String、datetime 映射成 LocalDateTime,这中间非常容易手滑。接着写 MyBatis XML,resultMap 要一个字段一个字段对齐,Base_Column_List 要列全字段,insert 和 update 又要重新写一遍字段名,三遍重复操作下来,出错的概率极高。
有了这个工具,你只需要把建表语句放进去,它自动完成三件事:第一,解析表名、字段名、字段类型、注释和主键;第二,按照命名规则把字段转成 Java 驼峰属性,并映射出对应的 Java 类型;第三,基于解析结果拼装出完整的实体类代码和 MyBatis XML 代码。整个过程是一套固定的逻辑,适合交给程序来做,人去做反而又慢又容易错。
还有一个容易被忽略的好处是“统一风格”。团队里每个人写实体类的习惯不一样,有人加 @Data 有人不加,有人喜欢在字段上写 @TableField,有人觉得 MyBatis-Plus 默认驼峰转换就够了。这个工具把大家拉到同一套规范上,代码审查时一眼扫过去,风格基本是一致的。
1.2 适用人群和使用场景
这个工具主要面向 Java 后端开发者,特别是用 MyBatis 或 MyBatis-Plus 的同学。如果你还在用 JDBC 手写 ResultSet,或者只写 JPA/Hibernate,那它的转换逻辑可以参考,但生成模板不一定贴合你的需求。另外,刚学 MyBatis 的新人也可以拿它当学习辅助工具,比如对照 DDL 和生成的 XML,搞清楚 resultMap 到底是怎么映射的,insert 为什么需要 useGeneratedKeys,动态更新为什么要用 <set>。
从使用场景来看,最实用的其实是这些地方:新模块建表后快速生成基础 CRUD;表结构改了字段,重新跑一遍生成新代码,对比 git diff 就知道哪里要调整;老项目做数据库表文档整理,需要把几十张表的结构转成标准实体类。我自己的体验是,最频繁的使用时机是设计评审结束后,需求要落地了,开发前先建表,然后一次性把实体和 mapper 骨架生成出来。
不过也要说清楚它的边界。它擅长的是单表结构转换,复杂视图、多表 join、存储过程、触发器这些它都处理不了。一个工具如果什么都想干,最后的结果通常是什么都干不好。所以在设计之初,我就把它定位成“单表 DDL 转 Java/MyBatis 的加速器”,够用就好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案选型与设计思路
工具的名字已经点明了技术形态:“mysql语句转换java实体类和mybatis语句工具html”。也就是说,它是跑在浏览器里的纯前端工具,核心代码是 HTML、CSS 和 JavaScript。这个选型不是拍脑袋决定的,我在动手之前对比过几种常见方案。
2.1 为什么用纯 HTML 而不是后端服务
最简单的替代方案是写一个 Spring Boot 接口,前端上传 DDL,后端用 Java 解析再返回结果。这种方案适合做成公司在线的代码生成平台,但对于个人日常使用来说太重了。你得启动服务、部署到某个环境,还要考虑多人同时用的时候会不会把 SQL 泄漏到服务端日志里。很多公司的代码和数据是敏感资产,员工并不愿意把 DDL 贴到内网之外的任何服务上。
用 IDEA 插件也可以,而且体验更好,因为你可以在编辑器里直接选择建表 SQL 片段来触发转换。但插件开发的学习成本不低,而且每换一个 IDE 版本或者换一个 IDE 品牌,插件可能就要重新适配。我当时想要的是一个“文件”,发到团队群里,大家双击就能用。HTML 完美满足这个要求。
纯 HTML 方案的另一个好处是离线可用。你把它保存到本地,没有网络、没有服务器,双击打开浏览器就能干活。DDL 内容不会上传到任何地方,对敏感系统尤其友好。而且 JavaScript 做字符串处理和模板拼接本来就非常灵活,一天时间就能把核心逻辑写完。
当然它也有局限。浏览器里的 JavaScript 不能直接访问本地文件系统,但这难不倒人,用 <input type="file"> 读取 SQL 文件,或者干脆把 DDL 粘贴到 textarea 里,体验上也不差。下载生成结果可以用 Blob 加 URL.createObjectURL 实现,实测没有任何问题。
2.2 转换流程与模块划分
整个工具的逻辑其实可以拆成五个模块,我在代码里也按这五块来组织函数,方便以后扩展。
第一个模块是输入预处理。用户粘贴的 DDL 可能带注释行、带 SET 语句、带多个建表语句,甚至可能是从 Navicat 或 Workbench 里导出的带格式的完整脚本。预处理阶段要把 -- 注释和 /* */ 块注释去掉,再按分号把多条语句切分开,只保留 CREATE TABLE 开头的部分。
第二个模块是 DDL 解析器。它是整个工具的核心,负责从一条建表语句里揪出表名、字段名、字段类型、字段长度、注释、主键信息。解析用正则表达式实现,后面我会详细展开。很多人觉得正则难读,但在这个场景里它是最直接有效的工具。
第三个模块是类型映射器。把 MySQL 类型翻译成 Java 类型,比如 varchar 变 String,bigint 变 Long,decimal 变 BigDecimal,tinyint(1) 变 Boolean 等。这个模块我是用一张映射表加一个函数来做的,新类型不好判断的时候可以再加规则。
第四个模块是代码生成器。它把解析出来的结构体,按照预设模板拼成 Java 实体类和 MyBatis XML。拼字符串听起来简单,但缩进、注解、import、换行都要处理到位,否则生成的代码看起来乱,反而不如手写。
第五个模块是输出与下载。生成结果展示在右侧 textarea 里,提供“复制”按钮和“下载文件”按钮,方便直接贴到 IDE 或保存成文件。
把模块拆开的直接好处是,后续想加“生成 Mapper 接口”或“生成 Service 层”的时候,只需要在代码生成器里新增一个函数,不需要动解析逻辑。整个架构非常轻,但五脏俱全。
3. 核心实现:DDL 解析、类型映射与代码生成
这一部分是工具的技术核心。代码量不大,但有不少细节容易踩坑。我会把关键的实现逻辑拆开讲,并且给出可以参考的代码片段。
3.1 DDL 解析正则与字段提取
拿到一条标准的建表语句,比如下面这样:
sql复制CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_name` varchar(50) NOT NULL COMMENT '用户名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
我首先要从里面取出表名 t_user,然后把中间括号里的字段定义按行拆分。表名用正则 /CREATE\s+TABLE\s+(?:IF NOT EXISTS\s+)??(\w+)?\s*\(/i 来匹配,能兼容可选的 IF NOT EXISTS 和反引号。括号里的内容不能用简单的字符串截取,因为字段定义里还有括号,比如 decimal(18,2) 和 enum('a','b'),所以我用字符串扫描的方式做括号深度匹配,从左括号开始,遇到 ( 加深,遇到 ) 减浅,深度回到 0 就找到了字段定义区。
拿到字段定义区之后,按换行符切分成数组,然后逐行处理。每一行可能是一个字段定义,也可能是主键声明、索引声明。我先过滤掉以 PRIMARY KEY、KEY、UNIQUE KEY、CONSTRAINT 开头的行,剩下的就是真正的字段行。
字段行的正则是我调试最久的点,最后稳定在一个版本上:
javascript复制const fieldReg = /^\s*`?(\w+)`?\s+([a-z0-9]+)(?:\(([^)]*)\))?\s*(unsigned)?\s*(zerofill)?\s*(NOT NULL|NULL)?\s*(?:DEFAULT\s+('[^']*'|[\w.()]+|CURRENT_TIMESTAMP(?:\([^)]*\))?))?\s*(?:COMMENT\s+'([^']*)')?/i;
捕获组从左到右分别是:字段名、类型、类型参数、unsigned 标记、zerofill 标记、空值约束、默认值、注释。实际使用中这条正则已经能覆盖绝大多数从 MySQL 导出的 DDL,但我也在页面里加了一个“解析预览”,把识别出来的字段列成表格,用户可以直观看到解析结果,有问题的可以手动调整,这点很关键。
主键的识别做了两层。一层是看字段行里有没有 AUTO_INCREMENT 关键字,另一层是匹配后面的 PRIMARY KEY (xxx) 声明。复合主键的情况,我会把所有主键字段名存进一个数组,最后统一打上标记。
javascript复制function parseDDL(ddl) {
ddl = ddl.replace(/\/\*.*?\*\//gs, '')
.replace(/--[^\n]*/g, '');
const tableMatch = ddl.match(/CREATE\s+TABLE\s+(?:IF NOT EXISTS\s+)?`?(\w+)`?\s*\(/i);
if (!tableMatch) return null;
const tableName = tableMatch[1];
const startIdx = ddl.indexOf('(', tableMatch.index);
let depth = 0, endIdx = -1;
for (let i = startIdx; i < ddl.length; i++) {
if (ddl[i] === '(') depth++;
else if (ddl[i] === ')') {
depth--;
if (depth === 0) { endIdx = i; break; }
}
}
const body = ddl.substring(startIdx + 1, endIdx);
const lines = body.split('\n');
const pkColumns = [];
const pkMatch = body.match(/PRIMARY\s+KEY\s*\(([^)]+)\)/i);
if (pkMatch) {
pkMatch[1].replace(/`?(\w+)`?/g, (m, name) => pkColumns.push(name));
}
const fields = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^(PRIMARY\s+KEY|KEY|UNIQUE\s+KEY|CONSTRAINT)/i.test(trimmed)) continue;
const m = trimmed.match(fieldReg);
if (!m) continue;
fields.push({
column: m[1],
type: m[2].toLowerCase(),
length: m[3] || '',
comment: m[8] || '',
isPrimaryKey: pkColumns.includes(m[1]) || /auto_increment/i.test(trimmed)
});
}
return {
tableName: tableName,
className: toPascalCase(tableName),
fields: fields
};
}
这段代码里有个细节值得说一下:我用括号深度匹配而不是正则来截取字段区,是因为 DDL 里可能出现多个括号嵌套,用贪婪匹配很容易把尾部表选项的括号也一起吞进来。扫描一遍字符串,深度归零的位置就一定是对的。
3.2 MySQL 到 Java 的类型映射策略
类型映射看起来是一张简单的表,但真正写起来会发现很多边界情况。我的做法是先建一个基础映射表,再写一个 mapJavaType 函数做兜底判断。常用映射关系如下:
| MySQL 类型 | Java 类型 | 说明 |
|---|---|---|
| varchar / char / text / longtext / mediumtext / tinytext | String | 文本系列基本都映射成 String |
| int / integer | Integer | 普通整数 |
| bigint | Long | 主键常用 |
| tinyint | Boolean / Integer | 根据长度判断,tinyint(1) 一般当 Boolean |
| smallint / mediumint | Integer | 长度不大 |
| decimal / numeric | BigDecimal | 金额等精确计算必须用 BigDecimal |
| float | Float | 不推荐做金额字段 |
| double | Double | 常规浮点 |
| date | LocalDate | JDK8 时间类型 |
| datetime / timestamp | LocalDateTime | 注意参考团队时间类型规范 |
| time | LocalTime | 较少见 |
| blob / longblob / varbinary | byte[] | 二进制数据 |
| json | String | MySQL 5.7+ 的 JSON 类型常用 String 接收 |
| enum / set | String | 枚举在实体类里通常还是 String,不排除自定义枚举 |
映射函数我放在同一个工具类里,方便复用:
javascript复制function mapJavaType(type, length) {
const typeMap = {
'varchar': 'String', 'char': 'String', 'text': 'String',
'longtext': 'String', 'mediumtext': 'String', 'tinytext': 'String',
'int': 'Integer', 'integer': 'Integer', 'smallint': 'Integer',
'mediumint': 'Integer', 'bigint': 'Long', 'decimal': 'BigDecimal',
'numeric': 'BigDecimal', 'float': 'Float', 'double': 'Double',
'date': 'LocalDate', 'datetime': 'LocalDateTime',
'timestamp': 'LocalDateTime', 'time': 'LocalTime',
'blob': 'byte[]', 'longblob': 'byte[]', 'varbinary': 'byte[]',
'json': 'String'
};
if (type === 'tinyint') {
return length === '1' ? 'Boolean' : 'Integer';
}
return typeMap[type] || 'String';
}
这里有几个经验。第一,tinyint(1) 和 tinyint(4) 在 MySQL 里本质上都是 tinyint,但业务含义完全不同。像逻辑删除字段 deleted、状态开关 enabled 经常是 tinyint(1),映射成 Boolean 更自然;而 status 这种多状态值用 tinyint(4),映射成 Integer 更合理。所以我引入了长度参数来区分。
第二,datetime 到底用 LocalDateTime 还是 java.util.Date,要看项目的基础设施。老项目可能还用 Date,新项目基本都是 LocalDateTime。我做成一个选项,默认 LocalDateTime,也可以在页面上切回 Date,这样兼容性更好。
第三,decimal 必须映射成 BigDecimal,这个没有商量余地。浮点数在二进制里是不精确的,用 Float 或 Double 接收金额字段,迟早会出事故。这个如果有人有疑问,你去搜一下 0.1 + 0.2 !== 0.3 就能理解。
3.3 实体类与 MyBatis XML 的生成逻辑
解析结果拿到之后,代码生成就是纯字符串拼接。实体类生成我设了两个选项:是否使用 Lombok,是否使用 MyBatis-Plus 注解。默认都勾上,因为现在大多数项目都这么写。
java复制package com.example.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("t_user")
public class TUser {
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户名 */
@TableField("user_name")
private String userName;
/** 年龄 */
private Integer age;
}
生成逻辑里我先拼包名和 import,再把字段块循环拼接。Lombok 开启时只加一个 @Data,字段本身不用生成 getter/setter,代码简洁很多。MyBatis-Plus 注解模式下,主键字段生成 @TableId(type = IdType.AUTO),普通字段生成 @TableField("user_name") 显式指定列名,避免依赖全局下划线转驼峰配置。表名用 @TableName 注解括起来,防止表名和关键字冲突。
MyBatis XML 的生成要复杂一些。传统 MyBatis 需要 resultMap、Base_Column_List、selectById、selectList、insert、update、deleteById 这段固定套路。我按模板把它们铺出来,字段通过循环填充,而不是写死。
xml复制<mapper namespace="com.example.mapper.TUserMapper">
<resultMap id="BaseResultMap" type="com.example.entity.TUser">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="user_name" property="userName" jdbcType="VARCHAR"/>
<result column="age" property="age" jdbcType="INTEGER"/>
</resultMap>
<sql id="Base_Column_List">
id, user_name, age
</sql>
<select id="selectById" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from t_user
where id = #{id}
</select>
<select id="selectList" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from t_user
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into t_user (user_name, age)
values (#{userName}, #{age})
</insert>
<update id="update">
update t_user
<set>
<if test="userName != null">user_name = #{userName},</if>
<if test="age != null">age = #{age},</if>
</set>
where id = #{id}
</update>
<delete id="deleteById">
delete from t_user
where id = #{id}
</delete>
</mapper>
生成这段 XML 时,我的做法是分块拼字符串。resultMap 的循环和 Base_Column_List 的循环分开写,中间用换行连接。需要注意 insert 语句不能包含自增主键字段,所以我生成字段列表时会过滤掉 isPrimaryKey 且类型里带 AUTO_INCREMENT 的字段。而 update 语句的 <set> 里我逐个字段生成 <if> 标签,这样更新时只更新传入的非空字段,不会误把 null 覆盖进数据库。
4. 实操演示:完整页面代码与运行效果
理论的实现讲完了,我们来点实际的。我建议你用浏览器打开 DevTools,把下面的代码敲进去,跑通了再去改自己的模板。
4.1 页面布局和交互设计
页面我做得非常朴素,左侧是两个 textarea,一个放 DDL 输入,一个放选项配置;右侧是两个 textarea,分别显示生成的实体类和 MyBatis XML。顶部是功能按钮:转换、复制实体类、复制 XML、下载全部。布局用简单的 flex 容器实现,没有引入任何 UI 框架,因为核心是算法不是样式。
交互流程也很简单:用户把 DDL 粘贴进输入框,点击转换按钮,JavaScript 读取输入框的值,调用 parseDDL 解析,再调用 generateEntity 和 generateXml,把结果塞进右侧 textarea。代码里我绑定了三个按钮事件:convert、copyEntity、copyXml、download。下载功能用到 Blob,文件名默认取表名。
这里有一个实际开发中容易忽略的点:剪贴板 API navigator.clipboard 在 file:// 协议下或者非 HTTPS 环境下不可用,直接调用会抛异常。所以我写了一个兼容方案,优先用 navigator.clipboard.writeText,失败就创建一个不可见的 textarea,手动 document.execCommand('copy')。这种老办法虽然丑,但兼容性最好,在本地双击打开的 HTML 里也能正常复制。
4.2 关键 JavaScript 函数拆解
整个页面的核心逻辑其实都集中在几个函数里。除了前面已经写过的 parseDDL 和 mapJavaType,还有两个工具函数负责命名转换:
javascript复制function toCamelCase(str) {
return str.replace(/_([a-z])/g, (m, c) => c.toUpperCase());
}
function toPascalCase(str) {
const camel = toCamelCase(str);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
命名转换看似简单,但有一个坑:表名带数据库前缀,比如 db_user,我到底要不要把 db 去掉?实际情况里不同团队习惯不一样,所以我在选项里加了一个“去掉表名前缀”的开关,默认关闭。字段名也有类似情况,比如 user_name 转成 userName 没问题,但单个词 name 转成 name,toCamelCase 不会有影响。
生成实体类的核心函数,我把关键逻辑简化如下:
javascript复制function generateEntity(info, options) {
const importSet = new Set(['java.io.Serializable']);
if (options.useLombok) importSet.add('lombok.Data');
if (options.useMybatisPlus) importSet.add('com.baomidou.mybatisplus.annotation.*');
const fields = info.fields.map(f => {
f.javaType = mapJavaType(f.type, f.length);
f.javaName = toCamelCase(f.column);
if (f.javaType.startsWith('Local')) importSet.add('java.time.' + f.javaType);
if (f.javaType === 'BigDecimal') importSet.add('java.math.BigDecimal');
return f;
});
const lines = [];
lines.push(`package ${options.packageName};`);
lines.push('');
importSet.forEach(imp => lines.push(`import ${imp};`));
lines.push('');
if (options.useLombok) lines.push('@Data');
if (options.useMybatisPlus) lines.push(`@TableName("${info.tableName}")`);
lines.push(`public class ${info.className} implements Serializable {`);
lines.push('');
for (const f of fields) {
if (f.comment) lines.push(` /** ${f.comment} */`);
if (options.useMybatisPlus) {
if (f.isPrimaryKey) {
lines.push(' @TableId(type = IdType.AUTO)');
} else {
lines.push(` @TableField("${f.column}")`);
}
}
lines.push(` private ${f.javaType} ${f.javaName};`);
lines.push('');
}
lines.push('}');
return lines.join('\n');
}
生成 XML 的函数思路类似,也是先用 fields 拼出 resultMap 和 Base_Column_List,再拼 SQL。为了不让代码太长,我在文章里不贴完整函数,但你只要照着上面 XML 示例的结构,用反引号模板字符串把字段循环填进去,就能得到同样的效果。有一个经验是:写这种生成器一定要自己先跑一遍,看看生成的代码是不是符合你的项目规范,缩进和换行不影响功能,但会影响团队代码格式的统一。
4.3 一次完整的 DDL 转换演示
我们用一个真实感的建表语句来跑一遍。假设要建一张用户表,我故意把字段类型弄得丰富一些,覆盖常见的坑:
sql复制CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_name` varchar(50) NOT NULL COMMENT '用户名',
`nick_name` varchar(50) DEFAULT NULL COMMENT '昵称',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`amount` decimal(18,2) DEFAULT '0.00' COMMENT '账户余额',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除',
`status` tinyint(4) DEFAULT '1' COMMENT '状态',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
工具生成的实体类大致是:
java复制package com.example.entity;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("t_user")
public class TUser implements Serializable {
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户名 */
@TableField("user_name")
private String userName;
/** 昵称 */
@TableField("nick_name")
private String nickName;
/** 年龄 */
@TableField("age")
private Integer age;
/** 邮箱 */
@TableField("email")
private String email;
/** 账户余额 */
@TableField("amount")
private BigDecimal amount;
/** 创建时间 */
@TableField("create_time")
private LocalDateTime createTime;
/** 更新时间 */
@TableField("update_time")
private LocalDateTime updateTime;
/** 逻辑删除 */
@TableField("deleted")
private Boolean deleted;
/** 状态 */
@TableField("status")
private Integer status;
}
可以看到 tinyint(1) 被正确映射成了 Boolean,tinyint(4) 映射成了 Integer,decimal(18,2) 映射成了 BigDecimal,datetime 映射成了 LocalDateTime,这些都是我明确在类型映射函数里处理过的。生成的 XML 则按照前面展示的模板,把 t_user 的所有字段填充进 resultMap 和基础 CRUD 语句,基本可以直接粘到项目里用。
5. 常见问题与排查技巧实录
工具写出来之后,并不是所有 DDL 都能一次解析成功。我在自己使用和给同事试用过程中,遇到了不少奇奇怪怪的输入,整理出来几个高频问题,可以给你排查提供参考。
5.1 解析失败的典型原因
最常见的是把多条建表语句一次性粘贴进来。我的解析函数默认只处理第一条 CREATE TABLE,所以多表脚本需要先手动拆开,或者我代码里先按 ; 分割成数组,再循环处理每一段。现实中我建议一个页面只处理一张表,生成结果更清晰。
第二种常见问题是字段类型带 unsigned 和 zerofill,比如 int(10) unsigned NOT NULL。如果正则里没有显式跳过这两个关键字,类型解析会把 int 当成类型,但后面的 unsigned 会被误认为下一个字段,导致整行匹配失败。我的解法是在类型映射函数里把 unsigned 和 zerofill 提取并丢弃,不参与 Java 类型判断。unsigned 只影响取值范围,不影响 Java 侧的表示方式。
第三种是默认值里有引号嵌套,例如 DEFAULT 'a,b,c' 或者 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP。我的字段正则里默认值部分写了一个可选分支,可以匹配单引号字符串、纯数字、CURRENT_TIMESTAMP() 这类函数表达式。如果遇到更诡异的默认值,我会在正则里再加分支,但更多时候是直接修改原始 DDL,把默认值去掉再解析。
我强烈建议在工具里加一个“解析预览”区域,把每个字段独立拆出来显示。一旦某行解析失败,页面上会少一个字段,你立刻就能发现是哪个字段出了问题,而不是等生成完代码才发现缺列。可视化反馈是排查这类问题最有效的方式。
5.2 类型映射和生成结果的坑
类型映射的坑大多集中在数字类型上。tinyint(1) 究竟是 Boolean 还是 Integer,不同公司有不同理解,所以我的工具里放了一个选项,默认按长度自动判断,但你也可以强制所有 tinyint 都映射成 Integer。还有 bigint(20) 到底要不要保留后面的长度,其实 Java 的 Long 跟显示宽度无关,所以我解析完直接丢掉 length 参数,不参与类型映射。
另一个容易出问题的是主键识别。如果表用了复合主键,实体类里会有多个字段打上 @TableId 注解,这在 MyBatis-Plus 里是不支持的。MyBatis-Plus 的 @TableId 只能标一个字段,复合主键建议使用 @TableId 加 @TableField 组合,或者干脆不用注解,手动写 XML 处理。我在生成器里做了个简单处理:只有第一个主键字段生成 @TableId,其余主键字段只保留 @TableField。
生成 XML 时还有一个容易忽略的点:insert 语句如果带了自增主键字段且没有数据库默认值,useGeneratedKeys 的 keyProperty 会报错。我的做法是生成插入字段列表前,把 AUTO_INCREMENT 字段过滤掉。这个过滤标记在解析阶段就准备好了,字段对象里 isPrimaryKey 配合 autoIncrement 一起判断。
5.3 团队落地时的几点建议
如果你打算把这个工具分享给团队用,我觉得有三件事值得提前做。
第一,把生成模板改成你们团队统一的风格。比如有的人喜欢在每个字段上方写 /** 注释 */,有的人喜欢放行尾注释;有的项目用 @TableName 有的不用。这些差异完全可以做成配置项,让每个同学按自己项目调,但团队内部最终要统一,否则工具就失去了意义。
第二,把“解析预览”当成一个必要步骤。不管工具多智能,手工核对一遍字段列表永远比写完代码后发现缺字段划算。我用的方式是解析完先展示一个字段表格,确认无误再点生成。这个过程看起来多一步,实际节省了后面排查的时间。
第三,在生成的代码头部加一行模板标识,比如 <!-- Generated by mysql-to-mybatis tool -->。这样做的好处是,代码审查时看到这个标记就知道这是生成代码,不需要逐行审,只需要关注后续手写部分。生成文件如果被手工改过,标识行被删掉,也不会影响功能。
6. 最后补充:一些个人使用心得
工具用到现在,我最大的体会是:这种转换器真正厉害的地方不在于“省几分钟”,而在于它逼着我把类型映射和命名规范想清楚了。以前写 datetime 字段有人用 Date 有人用 LocalDateTime,现在工具统一了;以前 tinyint(1) 到底是不是布尔,现在有了明确的映射规则。对于一个团队来说,统一的规则比省下来的几分钟更有价值。
最后再分享一个小技巧:如果你要频繁生成代码,建议给工具加一个简单配置记忆功能,把包名、Mapper 路径、是否使用 Lombok 这些选项用 localStorage 存起来。下次打开页面不用重新选,直接粘贴 DDL 就能转换。我就因为这个细节,让工具的使用率翻了不止一倍。整个页面不过几百行代码,维护起来也不费劲,如果你也有类似的需求,照着这个思路做一个,成本比你想的低很多。
