ARTICLE DETAIL

资讯详情

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

JDBC ResultSetMetaData 实战:从元数据到高效代码生成器

JDBC ResultSetMetaData 实战:从元数据到高效代码生成器 干Java开发这些年最让我头皮发麻的活儿之一就是照着数据库表手写实体类。一个表几十个字段类型要看注释要抄命名要转驼峰写错一个类型编译期都过不了等跑起来才发现数据没了。后来在做代码生成器的时候我把 JDBC 里的 ResultSetMetaData 彻底研究了一遍才发现这个接口才是自动生成代码的关键所在。它不复杂但用好了能让你的生成器从“玩具”变成“工具”。这篇文章就是一次完整的记录适合写过 JDBC、想自己做代码生成器或者开发框架工具的 Java 后端读者。1. ResultSetMetaData到底是什么为什么生成器非用它不可1.1 代码生成器最需要的东西它全都有代码生成器干的事情说白了就是把“数据库表结构”翻译成“一段代码”。要做这个翻译你需要知道目标表有哪些列、每列什么类型、能不能为空、是不是自增主键、精度多少、注释是什么。这些信息里最基础也最容易出错的就是“列名”和“列类型”。手写实体类的时候我经常在 Decimal 和 Timestamp 之间纠结一个字段一个字段地看数据库客户端眼睛都花了。ResultSetMetaData 就解决这个痛点。它是 java.sql 包下的一个接口只要执行了一条 SQL从 ResultSet 里就能取到它然后通过一组 getter 方法把结果集的列信息全部拿出来。注意它描述的是“一个查询结果集”的元数据不是数据库字典但它确实能精准告诉你查询返回了哪些列、每一列的类型码是多少、长度多少、能否为 null。对于代码生成器来说这就是第一手材料。最早我把这接口当辅助工具用后来才发现它才是生成器里最稳定的数据源之一。因为 DatabaseMetaData 返回的信息虽然更全但不同数据库驱动的实现差别很大而 ResultSetMetaData 直接由驱动根据真实查询结果生成反而更接近“你实际要处理的数据”。1.2 一行空查询所有列信息自己送上门先看一个最基础的原型代码Connection conn DriverManager.getConnection(url, user, password); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(select * from sys_user where 10); ResultSetMetaData meta rs.getMetaData(); int count meta.getColumnCount(); for (int i 1; i count; i) { System.out.println(meta.getColumnName(i) - meta.getColumnType(i)); }关键就在where 10这个条件。这个条件让查询不返回任何数据行但数据库驱动仍然会执行 SQL 解析生成完整的结果集元数据。这样你既不担心大数据量又能拿到所有列的描述。很多刚接触代码生成器的同学会写select * from table limit 1在 MySQL 里没问题但换到某些数据库就可能遇到语法差异而where 10是 ANSI SQL通用性最好。在拿到meta之后遍历索引从 1 开始而不是 0。这是 JDBC 的惯例写习惯之后倒不觉得别扭但不注意就会数组越界。1.3 ResultSetMetaData 常用方法速查方法作用代码生成器里的典型用法getColumnCount()结果集列数决定循环次数getColumnName(i)数据库物理列名转成 Java 属性名getColumnLabel(i)SQL 别名或显示标签判断是否使用了别名多用于报表表头getColumnType(i)返回java.sql.Types常量值作为类型映射的主要依据getColumnTypeName(i)数据库原生类型名如varchar辅助判断处理方言差异getColumnDisplaySize(i)列显示宽度侧面辅助长度判断getPrecision(i)/getScale(i)精度与小数位数判断 Decimal 是否需要保留 BigDecimalisNullable(i)是否允许 NULL决定包装类型还是基本类型isAutoIncrement(i)是否自增生成主键生成策略getTableName(i)列所属表名多表关联时拆分字段归属这里有个地方特别容易踩坑getColumnName和getColumnLabel的区别。getColumnName是数据库里真实的列名比如user_namegetColumnLabel是查询里作为显示标题返回的名字如果你写select user_name as userNamelabel 就是userName。代码生成器里我们要把物理列名转成驼峰属性所以绝大多数情况应该优先用getColumnName而不是 label。我之前见过有人拿 label 当成物理列名去匹配注释结果怎么都对不上就是这个原因。2. 代码生成器里的两种元数据来源ResultSetMetaData 与 DatabaseMetaData2.1 为什么很多人绕道去用 DatabaseMetaData除了 ResultSetMetaDataJDBC 还提供了另一个获取元数据的入口Connection.getMetaData()返回DatabaseMetaData。通过它能拿到当前数据库的表清单、字段清单、主键、索引、列注释信息非常“数据字典化”。MyBatis Generator、MyBatis-Plus Generator 这类老牌工具主要就是靠 DatabaseMetaData 干活。那 ResultSetMetaData 是不是就没用了不完全是这样。我实际做生成器的时候发现DatabaseMetaData 的信息虽然全但不同数据库的方言差异特别让人头疼。比如列类型数据库 A 返回VARCHAR数据库 B 返回VARCHAR2如果直接从字符串去映射 Java 类型得准备一大堆判断逻辑。而 ResultSetMetaData 的getColumnType返回的是统一的java.sql.Types整数常量比如Types.VARCHAR类型判断一下子变干净了。另外代码生成器最怕的是“元数据和你真实执行的 SQL 不一致”。DatabaseMetaData 告诉你这张表有 10 个字段但如果你要生成的视图或者查询 SQL 只选其中 5 个字段以表元数据为准就会生成多余代码。这个时候ResultSetMetaData 是唯一能精确描述“当前查询结果”的信息源。2.2 一个现实中的生成器整体流程我做的生成器大致是这么跑的获取数据库连接。通过DatabaseMetaData确认目标表存在拿到表注释、主键列、是否自增等信息。执行一条select * from 目标表 where 10的空查询取得ResultSetMetaData。遍历结果集列信息结合第 2 步拿到的注释和主键拼装成统一的ColumnInfo列表。把ColumnInfo丢给不同类型的模板函数生成实体类、Mapper、DTO。这个流程里ResultSetMetaData 是字段列表的主来源DatabaseMetaData 是注释和主键的补充来源。两路信息以“物理列名”为关联键合并。注意如果第 3 步的 SQL 里给列起了别名合并逻辑一定要用getColumnName而不是getColumnLabel否则后面查注释的时候会全是空值。2.3 两路元数据一表看清谁该做什么信息项ResultSetMetaDataDatabaseMetaData我的建议列名有getColumnName有COLUMN_NAME优先 ResultSetMetaData因为和查询一致列类型有getColumnType有TYPE_NAME以 ResultSetMetaData 的类型码为核心长度/精度有有优先 ResultSetMetaData列注释没有有REMARKS必须用 DatabaseMetaData 或 information_schema主键没有有getPrimaryKeys必须用 DatabaseMetaData是否自增有isAutoIncrement有IS_AUTOINCREMENT优先 DatabaseMetaData结果更稳定表名有getTableName有TABLE_NAME用 DatabaseMetaData避免不带库名时出错我还遇到过一种特殊情况某些国产数据库驱动的DatabaseMetaData.getTypeInfo()返回的类型字符串非常随意甚至同一个类型在不同版本驱动里大小写都不一样。反而是 ResultSetMetaData 的getColumnType整数码始终符合 JDBC 规范。从那以后我在自己的生成器里定了一条规则所有 Java 类型映射只认getColumnType字符串类型名只用来打印日志和做兜底判断。3. 手写一个迷你代码生成器从元数据到实体类3.1 准备环境与最基础的元数据采集代码这一节直接给可复制的方案。环境我用的 JDK 8 MySQL 8.0Maven 里引入dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency先定义一个保存列信息的 POJOpublic class ColumnInfo { private String columnName; // 物理列名 private String columnLabel; // 显示标签 private int columnType; // java.sql.Types 类型码 private String columnTypeName; // 数据库原生类型名 private int precision; // 精度 private int scale; // 小数位 private boolean nullable; // 是否允许 NULL private boolean autoIncrement; // 是否自增 private String remark; // 注释从 DatabaseMetaData 补全 // 省略 getter/setter }核心采集方法如下public static ListColumnInfo readColumns(Connection conn, String tableName) { ListColumnInfo columns new ArrayList(); String sql select * from tableName where 10; try (Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql)) { ResultSetMetaData meta rs.getMetaData(); int count meta.getColumnCount(); for (int i 1; i count; i) { ColumnInfo info new ColumnInfo(); info.setColumnName(meta.getColumnName(i)); info.setColumnLabel(meta.getColumnLabel(i)); info.setColumnType(meta.getColumnType(i)); info.setColumnTypeName(meta.getColumnTypeName(i)); info.setPrecision(meta.getPrecision(i)); info.setScale(meta.getScale(i)); info.setNullable(meta.isNullable(i) ! ResultSetMetaData.columnNoNulls); info.setAutoIncrement(meta.isAutoIncrement(i)); columns.add(info); } } catch (SQLException e) { throw new RuntimeException(读取表结构失败: tableName, e); } return columns; }注意一个细节这个方法里元数据信息是在 try-with-resources 中、ResultSet 还开着的情况下被复制进了ColumnInfo列表。等连接关闭后再去访问ResultSetMetaData本身是可能抛异常的所以千万不要把meta对象存起来留到后面用。先把该拿的都拿出来后面想怎么折腾都不怕。3.2 类型映射这一步最容易写出一堆 if-elseJDBC 类型到 Java 类型的映射是代码生成器里最关键的规则表。我常用的映射如下java.sql.TypesJava 类型说明BITBoolean / IntegerMySQL 中长度 1 映射 Boolean长度 1 映射 byte[]TINYINTByte / BooleanMySQL 中长度 1 且无符号常见于 BooleanSMALLINTShort注意使用包装类型INTEGERInteger默认包装类型兼容可空字段BIGINTLong生成主键时很常用FLOATFloat精度低注意业务选择DOUBLEDouble浮点类型DECIMALBigDecimal金额、精确计算必须用它NUMERICBigDecimal同上CHARString固定长度字符串VARCHARString最常见类型LONGVARCHARString长文本DATELocalDateJDK 8 以后推荐TIMELocalTime只存时间TIMESTAMPLocalDateTime时间戳类型BLOBbyte[]二进制CLOBString大文本其他Object兜底避免生成失败映射实现里我只看getColumnType的类型码不再去解析字符串。原因前面说过字符串类型名在不同数据库里差异太大。但 MySQL 的BIT有点特殊bit(1)和bit(64)都返回Types.BIT所以我还得看getPrecision判断长度。长度等于 1 时映射 Boolean否则映射 byte[]。这就是为什么每个ColumnInfo里都要保留 precision。字段生成时我建议一律使用包装类型不要用int、long这类基本类型。原因是数据库字段可能为 NULL如果用基本类型MyBatis 插入数据时会把 null 自动转成 0这种 bug 排查起来极其痛苦。生成 getter/setter 时也用包装类型保持一致。3.3 命名转换下划线转驼峰的标准写法表名、列名转驼峰是另一个高频需求。我的规则是列名user_name转userName表名sys_user转SysUser。注意一点很多数据库列名直接返回大写比如 Oracle 的USER_NAME转换前统一转成小写处理避免混进大写字母导致怪异的驼峰结果。public static String toCamelCase(String name, boolean capitalizeFirst) { String lower name.toLowerCase(); StringBuilder sb new StringBuilder(); boolean upper false; for (char c : lower.toCharArray()) { if (c _) { upper true; } else if (upper) { sb.append(Character.toUpperCase(c)); upper false; } else { sb.append(c); } } if (capitalizeFirst sb.length() 0) { sb.setCharAt(0, Character.toUpperCase(sb.charAt(0))); } return sb.toString(); }这个函数看着简单但边界情况不少。比如列名以_开头生成结果会很怪我一般会在转换前过滤掉首尾下划线。再有就是全大写的缩写单词比如userURL如果不做额外处理转驼峰后可能变成userUrl这个通常是可以接受的。3.4 用字符串拼接生成实体类生产级工具应该上 FreeMarker 或 Velocity但为了把原理讲透这里直接用 StringBuilderpublic static String generateEntity(String tableName, ListColumnInfo columns) { String className toCamelCase(tableName, true); StringBuilder sb new StringBuilder(); sb.append(import java.math.BigDecimal;\n); sb.append(import java.time.LocalDateTime;\n); sb.append(import java.time.LocalDate;\n); sb.append(import java.time.LocalTime;\n); sb.append(import java.util.Date;\n\n); sb.append(public class ).append(className).append( {\n\n); for (ColumnInfo col : columns) { String javaType mapJavaType(col); String fieldName toCamelCase(col.getColumnName(), false); sb.append( /** ).append(col.getRemark() null ? : col.getRemark()).append( */\n); sb.append( private ).append(javaType).append( ).append(fieldName).append(;\n\n); } for (ColumnInfo col : columns) { String javaType mapJavaType(col); String fieldName toCamelCase(col.getColumnName(), false); String methodSuffix Character.toUpperCase(fieldName.charAt(0)) fieldName.substring(1); sb.append( public ).append(javaType).append( get).append(methodSuffix).append(() {\n); sb.append( return ).append(fieldName).append(;\n); sb.append( }\n\n); sb.append( public void set).append(methodSuffix).append(().append(javaType).append( ).append(fieldName).append() {\n); sb.append( this.).append(fieldName).append( ).append(fieldName).append(;\n); sb.append( }\n\n); } sb.append(}\n); return sb.toString(); }这段代码本身不难但生成出来的类已经能直接扔进项目编译了。如果你想简化 getter/setter可以生成一个带Data注解的类是更常见的做法前提是项目里有 Lombok。我早期图省事生成的实体类全用 Lombok后来接手新项目没有 Lombok改模板花了不少时间。所以生成器最好把“是否使用 Lombok”做成一个开关而开关控制的逻辑都落在模板层。3.5 注释补齐ResultSetMetaData 做不到的事ResultSetMetaData没有提供获取列注释的方法。因为注释属于数据库对象的“业务属性”不在结果集的运行期描述里。所以我的生成器会用DatabaseMetaData或直接查information_schema来拿SELECT COLUMN_NAME, COLUMN_COMMENT, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA your_db AND TABLE_NAME sys_user ORDER BY ORDINAL_POSITION;然后把查询结果和readColumns得到的列表按列名进行合并。合并时也有讲究如果 resultSetMetaData 返回的是全大写列名那合并之前统一转成小写如果列名里有特殊字符要如实保留不要拦截。这类脏数据我在真实业务表里见得不少比如字段叫user name中间带空格虽然不规范但生成器不能因为一个空格就崩掉。4. 常见问题与排查实录4.1 连接关闭后元数据就“断片”了这个坑我踩过一次很典型。在获取完 ResultSetMetaData 之后早早就把Connection关了然后想再调用meta.getColumnName(i)结果直接抛异常。不同数据库驱动对这个行为的容忍度还不一样MySQL 有时候没事但换到某些数据库就立刻炸。解决方案上面提过必须在 ResultSet 存活期间把ColumnInfo全部复制到普通 Java 对象中。连接可以随便关ListColumnInfo永远安全。4.2 列名和别名搞混注释全对不上有一次给一张视图生成代码SQL 为select user_id as userId, user_name as username from ...。我用getColumnLabel拿了“列名”然后拿它去匹配 DatabaseMetaData 的列注释结果匹配率为零。后来打印日志才发现getColumnLabel返回的是别名userId而物理列名是user_id。从那以后生成器的合并 key 一律使用getColumnName只在渲染表头或导出场景才用getColumnLabel。4.3 表名直接拼接 SQL被我说烂的风险生成器因为要执行select * from 表名 where 10表名如果直接拼进 SQL就会有注入风险。尤其当你做成一个 web 服务用户传入表名时恶意输入可能导致严重问题。我的做法是先用DatabaseMetaData.getTables校验表名存在性同时用正则限制表名格式比如只允许字母、数字、下划线且长度有限制。这个校验逻辑虽然很简单但能挡掉 99% 的异常输入。if (!tableName.matches([A-Za-z0-9_]{1,64})) { throw new IllegalArgumentException(非法表名: tableName); }4.4 批量生成几十张表时连接别乱关生成单张表很快但批量生成整个库的表时如果每张表都新建连接性能会非常难看。我统计过自己电脑上的数据20 张表每张表新建连接总耗时 5.2 秒复用同一个连接总耗时 0.8 秒。差别非常大。所以批量生成的入口最好维护一个连接按顺序跑完所有表的元数据采集最后统一关闭。每个 Statement 和 ResultSet 用 try-with-resources 管理就好连接不用动。4.5 不同数据库驱动返回值的差异MySQL 的getColumnName返回小写物理列名Oracle 返回大写所以匹配前统一toLowerCase()。Oracle 的getColumnTypeName可能返回VARCHAR2而 MySQL 返回VARCHAR所以不要依赖字符串做主要映射。某些驱动对isAutoIncrement支持不完善总是返回 false这种情况我可以接受至少不会生成错误的持久化策略。遇到这些差异最好的办法不是猜而是在开发生成器时写一个“元数据打印器”把所有列信息原样打出来对着真实输出调试。我每次适配一个新数据库第一步永远是打印一遍getColumnType和getColumnName的真实返回值然后再去改映射表。这个习惯帮我省掉很多文档没有回答的疑问。5. 扩展思路把 ResultSetMetaData 用在更多场景5.1 动态报表导出不用预知列名代码生成器之外ResultSetMetaData 还有个很实用的场景动态报表导出。比如导出一个查询的结果到 Excel传统做法是写死表头。但如果你用 ResultSetMetaData就可以在运行时拿到所有列名作为表头数据行再从 ResultSet 里循环取。这样同一个导出方法能处理任意 SQL业务方只要传一条 SQL 过来导出组件就能自动适配。这个功能几乎不需要额外类十几行代码就能实现。5.2 从实体类扩展到 Mapper XML、DTO、VO把元数据抽取成ColumnInfo列表后生成实体类只是第一站。同一个列表稍微改一下模板就能生成Mapper 接口里的insert、select方法签名XML 里的resultMap其中property用驼峰属性名column用物理列名DTO/VO 类字段结构和实体类类似但注释、校验注解可以不同Swagger 注解ApiModelProperty(value 注释)这些信息全都有来源。我自己做的工具里模板函数是按“产物类型”拆分的ColumnInfo是唯一入参。新增一种代码产物不需要改元数据采集逻辑只要写一个模板函数就行。这种低耦合方式让团队里的同事也能快速参与进来。5.3 顺手生成数据库文档代码生成器做完后我意外发现它还能当数据库文档生成器。把ResultSetMetaData拿到的列名、类型、长度加上 DatabaseMetaData 拿到的注释、主键渲染成 Markdown 或 Word 表格就是一份数据字典。后来团队做系统拆分、数据迁移时这份自动生成的文档救了不少人。它可能不是最精美的但绝对不会和实际表结构脱节因为每一行都是从数据库实时读出来的。最后再分享一个我自己的习惯元数据读取代码写完之后不管生成器目标是什么先写一个打印所有列信息的 main 函数跑一遍把每个数据库驱动的实际返回值打印出来。因为不同驱动对getColumnType、getColumnName的返回并不完全一致先看清楚真实数据再写类型映射能少踩很多坑。这个习惯让我在后面接各类数据库适配时省了大量时间也让我对 ResultSetMetaData 的理解从会用到用顺真正把它变成了手头工具箱里最趁手的那一把。
返回列表