如果你的日常工作是Java后端,那你多半经历过这个场景:DBA扔过来一段MySQL建表语句,让你本周把相关接口写完。你打开数据库客户端,复制建表语句,然后开始干一件纯体力活——照着字段手写Java实体类,手写Mapper接口,手写MyBatis XML里的resultMap和一堆增删改查标签。十来个字段还好,二十几个字段的时候,光是保证每个属性名和column对得上,就够你眼睛酸一阵了。更不用说有些同事建表还喜欢用order、desc这种保留字当列名,工具类不帮你兜底的话,上线就是事故。
我花了一个周末做了一件事:把“MySQL建表语句转Java实体类和MyBatis语句”这个过程做成一个零依赖的单页HTML工具。粘贴SQL,点一下转换,实体类、Mapper接口、XML映射文件一次生成。这篇文章就把这个工具为什么值得做、核心代码怎么写的、以及测试中踩到的那些坑,完完整整讲一遍。
1. 为什么会有这个工具:新表接入的速度瓶颈
1.1 一次典型的"建表→写CRUD"流程
先还原一下我自己的日常工作。接到一个新需求,建表SQL通常是这样的:
- 一个业务模块少则两三张表,多则十几张表;
- 每张表字段数普遍在 10 到 25 个之间;
- DBA 给的是纯SQL文本,我需要在代码里新建 Entity、Mapper接口、Mapper XML。
然后就是机械操作:打开Navicat看字段,对照着敲 private Long id;,把注释抄进去;接着写Mapper接口的五个方法签名;再打开XML,写resultMap、写Base_Column_List、写selectByPrimaryKey、写delete、写两个insert、两个update。字段一多,复制粘贴改名字是最考验耐心的环节,改错一个字母,编译不报错,运行才报 Unknown column。
我粗算过一笔账:10个字段的表,正常工作速度下,实体类大概5分钟,Mapper接口2分钟,XML要10到15分钟,加起来接近20分钟。如果一个月要接30张新表,光这一步就是10个小时。这10个小时里没有任何创造性,全是人肉翻译。
市面上不是没有替代品:在线SQL转实体类的网站不少,但公司内网的表结构不可能往外贴;IDE插件和MyBatis Generator(MBG)功能强大,可很多项目真不需要生成那么多连表查询和方法重载;MBG还要配置XML、依赖、运行命令,一次性小需求用起来反而觉得重——初始化成本比手写还高。所以我才动了念头:自己做一个刚好能覆盖日常 80% 场景的转换工具。
1.2 为什么是单页HTML而不是IDE插件
做过技术选型的同学都知道,一个工具没被用起来,通常不是功能不够,而是使用成本太高。我给自己定了几条硬性要求:
- 双击就能用,不需要安装环境;
- 完全不联网也能跑,表结构不出本机;
- 不需要维护服务端,丢给同事一个文件就行;
- 源码可以随手改,谁有需求谁自己加。
单页HTML是唯一同时满足这些条件的形态。一个 tool.html 文件,内部用原生JavaScript写完所有逻辑,双击打开浏览器就能工作。我把文件放到团队内网共享目录,谁接到新表就复制一份到本地用,或者直接在浏览器里打开共享路径。表结构从头到尾不经过任何第三方服务器,对涉及敏感数据结构的项目来说,这一点比在线工具重要得多。
缺点当然也有:单文件不适合做工程化管理,没有测试框架,改坏了容易自己踩自己。但对我来说,它的定位就是“一把随身螺丝刀”,能拧螺丝就行,不需要是一台数控机床。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具长什么样:一个输入框、三段输出、零依赖
2.1 页面布局和整体交互
整个工具就是一个 HTML 页面,布局非常简单:左边是SQL输入区,右边是输出区,顶部一排操作按钮。输出区用 Tab 切换,分别展示实体类、Mapper接口和XML映射文件。
html复制<div class="container">
<div class="left">
<textarea id="sqlInput" placeholder="粘贴CREATE TABLE语句..."></textarea>
<div class="toolbar">
<button id="btnConvert">转换</button>
<button id="btnExample">载入示例</button>
<button id="btnClear">清空</button>
</div>
</div>
<div class="right">
<div class="tabs">
<button class="tab active" data-tab="entity">实体类</button>
<button class="tab" data-tab="mapper">Mapper接口</button>
<button class="tab" data-tab="xml">XML映射</button>
</div>
<pre id="output"></pre>
<button id="btnCopy">复制</button>
</div>
</div>
页面右侧我还放了一个可折叠的配置区,几个关键开关都在这里:
- Package路径和作者名,影响生成的包名和注释作者;
- 是否启用MyBatis-Plus注解(
@TableName、@TableId、@TableField); tinyint(1)映射成 Boolean 还是 Integer;- 时间字段映射成
LocalDateTime还是java.util.Date。
这几个开关不是拍脑袋加的花哨功能,而是我在团队里见过太多次“生成的代码风格和项目不一致,还得手工改”的返工场景。与其让用户生成完再逐个字段改,不如在生成前就选好风格。
2.2 SQL输入的容错设计
用户粘贴进来的建表语句,永远不会是你想的那种“干净”SQL。常见脏数据有:开头带 /* */ 块注释、中间夹着 -- 行注释、字段名有的带反引号有的不带、大小写混用、多余空行和Windows换行符。还有一个高频场景——同事直接把 SHOW CREATE TABLE 的输出贴过来,里面自带反引号、ENGINE=InnoDB、DEFAULT CHARSET=utf8mb4 那一串尾巴。
我的容错处理分四步:
- 先去掉
/* ... */块注释和--行注释,这一步能避免注释里的逗号和括号干扰后续解析; - 统一把
\r\n换成\n,避免逐行处理时出现空行和\r残留; - 用正则提取表名和表注释,再截取括号内的字段定义体;
- 按顶层逗号切分字段定义体,切分时要注意引号、反引号和括号内部的逗号不能误伤。
2.3 输出区的切换与复制体验
输出区我坚持用 <pre> 而不是 <textarea>,因为生成结果只读,<pre> 能天然保留空格和换行,展示效果更接近代码编辑器。Tab切换时三个输出分别缓存,避免重复计算。复制按钮用 navigator.clipboard.writeText,同时保留降级方案——不支持剪贴板API的旧浏览器就用临时 textarea + document.execCommand('copy')。这个细节看起来小,但实际使用中,一个不好用的复制按钮会让整个工具给人的感觉瞬间降级。
3. 解析MySQL建表语句:从正则到状态机的那点事
3.1 字段行的正则匹配策略
解析是整个工具最核心的部分。MySQL字段定义行的常见形态有这几种:
sql复制`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_name` varchar(50) DEFAULT NULL COMMENT '用户名',
`status` tinyint(1) DEFAULT '1' COMMENT '状态',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格',
我用的字段行正则大致长这样:
javascript复制const fieldRe = /^`?(\w+)`?\s+([\w]+)\s*(?:\(([\d,]+)\))?\s*(UNSIGNED)?\s*(ZEROFILL)?\s*(NOT\s+NULL|NULL)?\s*(AUTO_INCREMENT)?(?:\s+DEFAULT\s+(.+?))?(?:\s+COMMENT\s+'((?:[^'\\]|\\.)*)')?\s*$/i;
拆开看每个分组的作用:第一组提取字段名,第二组提取类型,第三组提取括号内的长度/精度,比如 decimal(10,2) 里的 10,2;后面的 UNSIGNED、ZEROFILL、空值约束、AUTO_INCREMENT 都是可选项;DEFAULT 和 COMMENT 是非贪婪匹配,避免把后面的内容吞进去。
这里有个特别容易翻车的点:DEFAULT 的值可能带引号,也可能是函数调用,比如 DEFAULT CURRENT_TIMESTAMP。如果正则在 DEFAULT 部分用 .+) 一路贪下去,碰到 COMMENT '创建时间' 时会连注释一起吞掉。我的做法是先按“从行尾反向找 COMMENT”的顺序处理:先匹配出 COMMENT 内容并从原行中移除,再解析剩余部分。这样 DEFAULT 部分的边界就干净了。
3.2 表名、注释、主键和索引的提取
切分出字段定义体的每一段之后,要判断这段到底是字段还是索引/约束。判断规则很简单:以 PRIMARY KEY、KEY、UNIQUE、CONSTRAINT、FULLTEXT 开头的行,都属于索引或约束,不参与字段生成,但主键信息要单独记下来——后面生成 @TableId 注解和 selectByPrimaryKey、deleteByPrimaryKey 全都要靠它。
javascript复制const tableNameRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?/i;
const tableCommentRe = /COMMENT\s*=\s*'([^']*)'/i;
const primaryKeyRe = /PRIMARY\s+KEY\s*\(([^)]+)\)/i;
表名正则能容忍 IF NOT EXISTS,表注释从括号外提取,主键从定义体内提取。需要注意,主键里的字段名可能是 (id) 带反引号的,提取后要统一去掉反引号再和字段列表比对。
还有一个隐蔽问题:按逗号切分定义体时,如果简单用 split(','),遇到 enum('a','b') 这种带逗号的类型,或者字段注释里写了中文逗号还带英文逗号的情况,直接就切碎了。所以我在工具里写了一个“状态机式”的切分函数,扫描每个字符,跟踪当前是否处于单引号、双引号、反引号或括号嵌套中:
javascript复制function splitTopLevel(sqlBody) {
const parts = [];
let current = '';
let inQuote = false;
let quoteChar = '';
let depth = 0;
for (let i = 0; i < sqlBody.length; i++) {
const ch = sqlBody[i];
if (inQuote) {
current += ch;
if (ch === quoteChar) {
if (sqlBody[i - 1] === '\\') continue;
inQuote = false;
}
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inQuote = true;
quoteChar = ch;
current += ch;
continue;
}
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) {
parts.push(current.trim());
current = '';
continue;
}
current += ch;
}
if (current.trim()) parts.push(current.trim());
return parts;
}
这个函数是我踩了无数次 enum 和注释逗号之后才定下来的版本,虽然代码量不大,但比任何“先找括号再切分”的花哨方案都稳。
3.3 解析失败时怎么兜底
再完整的解析方案也不可能覆盖所有SQL方言,尤其当输入来自同事手写的建表语句时。我的兜底策略很明确:单行失败不影响整体,宁可缺一个字段,也不让整个工具白屏。
每一行解析后,如果拿不到字段名,就把原始行推进 warnings 数组。生成输出时,在代码文件的顶部加一段注释提示:“以下行解析失败,请检查SQL格式:xxx”。这样用户一眼就能看到问题出在哪一行,复制出来的代码也不会因为隐藏了一个坏字段而在编译阶段爆雷。
提示:兜底的另一个作用是保护工具本身。用户拿一段完全不相关的SQL测试时,能快速得到一个可读的“解析失败”反馈,而不是一行晦涩的
undefined,这在工具类小项目里体验差异非常大。
4. 生成Java实体类:类型映射是核心,命名规则是脸面
4.1 MySQL类型到Java类型的映射表
实体类生成的核心是一张类型映射表。MySQL类型众多,但业务系统里高频出现的就十几种。我整理的默认映射如下:
| MySQL 类型 | Java 类型 | 备注 |
|---|---|---|
| tinyint(1) | Boolean | 如果关闭开关则映射为 Integer |
| tinyint / smallint / mediumint | Integer | |
| int / integer | Integer | |
| bigint | Long | 主键最常见类型 |
| decimal / numeric | BigDecimal | 金额、费率字段必须用这个 |
| float | Float | 精度敏感场景建议改 Double |
| double | Double | |
| varchar / char | String | |
| text / mediumtext / longtext | String | |
| date | LocalDate | |
| datetime / timestamp | LocalDateTime | |
| time | LocalTime | |
| bit(1) | Boolean | |
| blob / longblob | byte[] | 实际业务中很少用 |
映射表在代码里就是普通的键值对象:
javascript复制const typeMap = {
'tinyint': 'Integer',
'smallint': 'Integer',
'mediumint': 'Integer',
'int': 'Integer',
'integer': 'Integer',
'bigint': 'Long',
'decimal': 'BigDecimal',
'numeric': 'BigDecimal',
'float': 'Float',
'double': 'Double',
'varchar': 'String',
'char': 'String',
'text': 'String',
'mediumtext': 'String',
'longtext': 'String',
'date': 'LocalDate',
'datetime': 'LocalDateTime',
'timestamp': 'LocalDateTime',
'time': 'LocalTime',
'bit': 'Boolean',
'blob': 'byte[]',
'longblob': 'byte[]'
};
拿到映射结果后,还要动态收集需要的 import。比如字段里有 DECIMAL,自动加 import java.math.BigDecimal;;有 DATETIME 或 TIMESTAMP,自动加 import java.time.LocalDateTime;。如果一个导入类没被用到,生成的代码就会多一行红标,这种细节统一在字符串拼接阶段过滤掉。
4.2 字段名转驼峰和Boolean的特殊处理
字段名下划线转驼峰是最基础的一步,用正则一行就能搞定:
javascript复制function toCamelCase(name) {
return name.replace(/_([a-zA-Z0-9])/g, (m, letter) => letter.toUpperCase());
}
表名转类名时,第一个字母要大写,同时要处理 sys_user 这种下划线开头的情况。字段名转属性名时,首字母保持小写,但有个特例:如果字段叫 ID 或 Uid,生成的属性名是 id 和 uid 还是 ID 和 uID,结果天差地别。我统一按“全部转小写后再转驼峰”处理,保证属性命名的稳定。
Boolean 的处理需要单独拎出来说。很多开发者的习惯是把 status、deleted 这类字段用 tinyint(1) 存,他们内心默认这是一个布尔开关;但也有一部分老项目坚持用 Integer 存 0/1,因为数据库只有 TINYINT 没有原生布尔类型。所以我在配置区放了一个开关,默认把 tinyint(1) 映射为 Boolean,并支持一键切换回 Integer。这个开关虽然小,但避免了生成完再逐个改的尴尬。
4.3 注解体系怎么选:Lombok、MyBatis-Plus还是JPA风格
实体类生成我提供了三种输出模式,因为不同项目的技术栈差异实在太大:
- 纯净模式:只生成普通 POJO 加 Lombok 的
@Data,不绑定任何 ORM 注解。适合大多数 Spring Boot + MyBatis 项目,拿到就能用; - MyBatis-Plus 模式:额外生成
@TableName、@TableId、@TableField,适合项目里直接使用 MyBatis-Plus 的情况; - JPA 模式:生成
@Entity、@Id、@GeneratedValue、@Column,适合 Spring Data JPA 项目。
默认输出是 MyBatis-Plus 模式,因为和标题场景最贴合。以开头的 sys_user 表为例,生成的实体类大致长这样:
java复制package com.example.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统用户表
*/
@Data
public class SysUser {
/**
* 主键ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 状态:1启用 0禁用
*/
private Boolean status;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
生成实体类时还有一个小细节:字段注释里的单引号必须转义,否则会破坏 Javadoc 注释的闭合;注释内容里如果包含 */ 这种字符,也要替换掉,否则 IDE 会报告注释语法错误。这类边界情况都是我在测试时用真实表结构试出来的。
5. 生成MyBatis映射文件:容易"能跑但很烂"的五段拼接
5.1 resultMap和Base_Column_List的生成
XML 是 MyBatis 项目里最容易被写烂的部分。很多人手写 XML 时缩进混乱、jdbcType 缺失、resultMap 里 id 和 result 标签乱用,程序能跑,但后来维护的人看着就头大。
工具生成时我坚持几个原则:
- 主键字段用
<id>,其余一律用<result>; - 每个
<result>都带上jdbcType,避免 MyBatis 在特殊类型上的类型猜测问题; Base_Column_List里的列名顺序和表结构顺序一致,方便人和代码对齐;- 所有 XML 标签统一 4 空格缩进,保持和 IDEA 格式化风格一致。
xml复制<mapper namespace="com.example.mapper.SysUserMapper">
<resultMap id="BaseResultMap" type="com.example.entity.SysUser">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="username" property="username" jdbcType="VARCHAR"/>
<result column="password" property="password" jdbcType="VARCHAR"/>
<result column="status" property="status" jdbcType="TINYINT"/>
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List">
id, username, password, status, create_time
</sql>
</mapper>
jdbcType 的映射规则和 Java 类型映射表类似,BIGINT、VARCHAR、TIMESTAMP、DECIMAL、TINYINT,直接从 MySQL 类型字符串大写后匹配,匹配不到就留空,让 MyBatis 自己推断。
5.2 五个基础方法的拼接逻辑
单个表的增删改查,日常用到的基本方法就是六个:selectByPrimaryKey、deleteByPrimaryKey、insert、insertSelective、updateByPrimaryKeySelective、updateByPrimaryKey。
拼接逻辑里最需要小心的是 insertSelective 和 updateByPrimaryKeySelective,它们要靠 <if> 标签动态拼字段,任何一个字段的 test 条件写错,都会造成批量更新时漏字段。
xml复制<insert id="insertSelective" parameterType="com.example.entity.SysUser">
insert into sys_user
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="id != null">id,</if>
<if test="username != null">username,</if>
<if test="createTime != null">create_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="id != null">#{id,jdbcType=BIGINT},</if>
<if test="username != null">#{username,jdbcType=VARCHAR},</if>
<if test="createTime != null">#{createTime,jdbcType=TIMESTAMP},</if>
</trim>
</insert>
这里的 trim + suffixOverrides="," 组合是 MyBatis 比较标准的动态插入写法。工具生成时必须保证 test 里的属性名和实体类完全一致,否则运行期会抛 There is no getter for property named xxx。
另外还有个细节:如果表没有主键,selectByPrimaryKey 和 deleteByPrimaryKey 就不应该生成,否则生成的代码没法用。我在解析阶段会把主键字段单独存起来,生成方法时先判断主键是否存在。
5.3 逻辑删除、乐观锁和自动填充的预留
很多新项目已经用上了逻辑删除和乐观锁,但这些需求不是每张表都有,所以工具默认不强行生成相关代码,而是做“识别 + 提示”。
- 字段名为
deleted、类型为tinyint时,在实体类对应属性注释里追加“逻辑删除字段”,并在 XML 生成结果上方的注释中提醒,如果用 MyBatis-Plus 可以加@TableLogic; - 字段名为
version时,提示这是乐观锁字段,MyBatis-Plus 场景可加@Version; - 字段名是
create_time、update_time时,注释里提示可在数据库层维护默认值,也可在代码层做自动填充,但工具不擅自生成填充逻辑。
这样做的原因很务实:逻辑删除和乐观锁的实现方式在不同项目里差别很大,工具替你做了决定,反而可能和你们项目的约定不一致,到时候改起来比手写还费劲。提示而不越权,是生成类工具最该有的自觉。
6. 踩坑记录与实际使用感受:五张表测出来的修正
6.1 保留字、无符号、默认值这三个坑
工具开发完第一版,我找了团队里五张有代表性的表来测试。一张订单表、一张用户表、一张地址表、一张日志表、一张配置表,覆盖了常用字段类型和命名习惯。结果一测就出了三个之前的正则没覆盖到的问题。
第一个是保留字。订单表里有个字段叫 desc,生成 XML 时直接输出 desc = #{desc,jdbcType=VARCHAR}。这行 SQL 在 MySQL 里跑必报语法错误,因为 DESC 是保留字。处理方案是维护一个 MySQL 保留字列表,检测到字段名在里面时,生成 SQL 片段时用反引号包裹列名,同时在实体类注释里加一条“字段名为保留字,请确认是否需要改名”的提示。
第二个是 UNSIGNED。bigint(20) unsigned 的最大值超过了 Java Long 的范围,严格来说应该用 BigInteger,但绝大多数业务数据根本到不了那个量级。我最终选择默认还是映射成 Long,只在注释里标注 unsigned,并在使用说明里讲清楚这个取舍。如果不标注,将来真有超长数值时排查起来会非常痛苦。
第三个是 DEFAULT 值解析。第一次实现时,我的 DEFAULT 正则把 COMMENT '创建时间' 也吞进了默认值里,导致生成结果里出现一堆 defaultMessage 错位的怪数据。排查过程比较曲折,最后发现是正则贪婪匹配的问题,解决方式就是前面提到的“先反向剔 COMMENT,再解析 DEFAULT”。这个坑给我的教训是:解析 SQL 时,永远不要相信“输入很规范”这个假设。
6.2 从"生成就行"到"格式化良好"的改进
第一版生成的实体类虽然能用,但字段之间没有空行,Javadoc 紧紧贴着属性,缩进
