1. 整体设计:为什么用 JDBC 元数据接口
1.1 先理清需求:你要拿到的“表结构信息”到底是什么
如果你只是随口说要“获取表结构”,可能有两种完全不同的诉求。一种是运维人员想快速看一张表有哪些字段,直接执行几条查询,在客户端里看一眼就完了;另一种是开发要做一个功能模块,比如数据字典、元数据采集、ORM映射、代码生成器,需要程序化地拿到“表清单—字段—类型—注释—主键”这套结构化信息,并且要稳定、可重复执行。
我之前遇到的场景就是这种:某个数据中台项目要把业务库的元数据采集到中台里,后续要同步字段血缘、自动生成测试数据、给前端数据字典页面做展示。第一个接入的库就是达梦数据库。这个场景下,一次性脚本是撑不住的,必须做一套可复用的取数逻辑,于是核心问题就变成了:这段代码怎么设计,才能既兼容达梦,又尽量不锁死在某一种数据库上。
还有一个容易忽略的点:字段明细、类型、长度、精度、是否可空、默认值、注释、主键、外键,这些是常规信息,但一旦落到达梦上,还会引出许多附加问题。比如达梦支持定义模式下的大写表名、小写表名、加引号保留原始大小写;不同用户访问同一个表,schema 归属可能不同;注释可能存在 COMMENT 语句里,也可能需要走系统视图。这些细节在设计数据模型时就得先想清楚,否则写出来的代码只能对付最简单的 demo,稍微换个环境就各种查不到。
1.2 三条技术路线,为什么我倾向 DatabaseMetaData
要程序化拿表结构,通常有三条路。
第一条是查系统表或系统视图。达梦的系统视图有 ALL_TABLES、USER_TABLES、ALL_TAB_COLUMNS、ALL_CONSTRAINTS 这些,再深入一点还有 DBA_TABLES。MySQL 则是 information_schema.TABLES、information_schema.COLUMNS。问题一目了然:每个数据库的系统表长得不一样,换个库就要重写一堆 SQL。更麻烦的是,达梦还有多种兼容模式,不同模式下系统视图的暴露情况不同,直接查系统表的代码很容易出现环境依赖。
第二条路是写一层“数据库方言适配器”,每种数据库实现一套查询逻辑,最后统一对外输出。很多代码生成器就是这么做的。优点是可控性强,可以拿到自增序列、分区信息这类特殊内容;缺点是维护成本高,每接一个库都要写一套映射,而且系统视图在不同版本之间还会有字段差异。如果团队只维护一个库,这条路很香;如果明确要多库支持,就需要斟酌。
第三条路就是我用到的方案:直接走 JDBC 标准规范里的 DatabaseMetaData 接口。JDBC 规范中定义了 getTables、getColumns、getPrimaryKeys、getImportedKeys 等方法,各个数据库驱动厂商只要遵循标准,就会把这些方法实现到本库的系统表之上。达梦的 JDBC 驱动(DmJdbcDriver.jar)也实现了这些方法。选它作为主干,最大的好处是同一份代码在绝大多数关系型数据库上可以直跑,遇到个别细节问题再针对达梦做少量补偿。
有人会质疑元数据接口的性能。我的实测经验是,在几千张表、几万个字段的规模下,通过 JDBC 元数据接口读取,耗时在几十秒到两三分钟这个量级,完全能接受。相比之下,写几十个精心优化的系统表查询,省出来的时间并不多,而且通用性带来的收益,通常远超这几分钟的开销。
1.3 达梦的“兼容”与“不兼容”
达梦数据库有一个显著特点:它提供 Oracle 兼容模式,很多语法、数据类型、系统视图命名都向 Oracle 靠拢。这也导致一个常见误区——默认达梦和 Oracle 完全一致,直接把 Oracle 那套代码搬过来就完事。
实际上,达梦的 JDBC 驱动在元数据行为上,和 Oracle 驱动有不少差异。最典型的是模式名(Schema)的处理。达梦在用户登录后,默认模式一般就是用户名对应的模式,但如果配置了“模式名与用户名不一致”,或者用高权限用户连接,而你要读的是别人名下的表,那调用 getTables 时,catalog 和 schema 参数怎么传,就特别讲究。达梦驱动有时候会把 catalog 和 schema 混在一起处理;你只传 schema 不传 catalog,结果可能为空;你把 schema 当 catalog 传,返回结果又可能出乎意料。我调试最久的就集中在这一块。
另一个特殊点是大小写。达梦默认把不带引号的表名、字段名转成大写存储,但建表时加了双引号,名称会保留原始大小写。JDBC 元数据接口做匹配时,getTables 的 tableNamePattern 参数是按数据库内部存储的字符串去匹配的。也就是说,你拿小写字符串去匹配一个默认创建的大写表名,很可能匹配不上。这个问题直接影响了后面所有查询逻辑,我放到 2.4 节专门讲。
2. 核心实现:把“通用”做扎实的分层设计
2.1 统一数据模型先行
在写读表逻辑之前,先要把结果的数据结构定义好。所谓“通用”,不是说不考虑数据库差异,而是先把差异挡在模型外面,上层拿到的东西保持一致。
我当时设计了四个基础模型:
TableInfo:表名、表类型、所属 schema、注释、是否视图等基础属性。
ColumnInfo:字段名、JDBC 类型码、数据库原生类型字符串、长度、精度、是否可空、默认值、注释、字段序号。
PrimaryKeyInfo:主键名、主键字段集合、联合主键中每个字段的顺序。这个顺序很关键,联合主键顺序错了,生成索引 DDL 时会出问题。
ImportedKeyInfo:外键信息,包含外键表名、外键字段、引用表名、引用字段、更新规则、删除规则。
要注意一点:模型里不要只存数据库原生类型字符串。因为达梦返回的 typeName 可能是 VARCHAR、VARCHAR2、NVARCHAR2 这样五花八门的值。比较类型时,优先用 dataType 这个 JDBC 类型码,再结合实际长度做判断。比如判断“字符串类型”,不能只检查 typeName 是不是 varchar,应该看 getColumnType() 返回的是 Types.VARCHAR 还是 Types.NVARCHAR,再结合长度做后续分支。
模型字段宜多不宜少。我吃过亏:最初只设计了列名和类型,后面数据字典页面需要展示默认值和自增标志,又回来改模型。元数据采集这种功能,一次把模型定义宽一点,后面省很多事。
2.2 读取层:用 DatabaseMetaData 串起所有信息
核心代码不多,重点在于理解每一次调用的参数。我写一个不依赖数据库特性的类,内部持有 Connection,通过 connection.getMetaData() 拿到 DatabaseMetaData 对象,然后逐层读取。
获取表清单的核心方法如下:
public List<TableInfo> listTables(Connection conn, String schemaPattern, String tableNamePattern) throws SQLException { List<TableInfo> tables = new ArrayList<>(); DatabaseMetaData metaData = conn.getMetaData(); ResultSet rs = metaData.getTables( conn.getCatalog(), // catalog:达梦一般传 null 或者当前库 schemaPattern, // schema:传模式名,支持 % 通配符 tableNamePattern, // 表名模式,支持 % 通配符 new String[]{"TABLE", "VIEW"} // 只取表和视图 ); while (rs.next()) { TableInfo info = new TableInfo(); info.setTableCat(rs.getString("TABLE_CAT")); info.setTableSchema(rs.getString("TABLE_SCHEM")); info.setTableName(rs.getString("TABLE_NAME")); info.setTableType(rs.getString("TABLE_TYPE")); info.setRemarks(rs.getString("REMARKS")); tables.add(info); } rs.close(); return tables; }这里有一个关键参数策略:达梦下,catalog 参数到底传什么。我实测下来,最稳的做法是 catalog 传 null,schema 传要查询的模式名。部分版本的达梦驱动会把 catalog 当成 Oracle 的实例名处理,传了反而查不到结果。而 schema 传大写模式名,返回最稳定。如果拿不准模式名的大小写,可以先传一个 % 通配符,拿到当前用户可见的所有模式,再从中过滤。
然后是字段信息:
public List<ColumnInfo> listColumns(Connection conn, String schemaPattern, String tableName) throws SQLException { List<ColumnInfo> columns = new ArrayList<>(); DatabaseMetaData metaData = conn.getMetaData(); ResultSet rs = metaData.getColumns(conn.getCatalog(), schemaPattern, tableName, "%"); while (rs.next()) { ColumnInfo col = new ColumnInfo(); col.setColumnName(rs.getString("COLUMN_NAME")); col.setDataType(rs.getInt("DATA_TYPE")); col.setTypeName(rs.getString("TYPE_NAME")); col.setColumnSize(rs.getInt("COLUMN_SIZE")); col.setDecimalDigits(rs.getInt("DECIMAL_DIGITS")); col.setNullable(rs.getInt("NULLABLE")); col.setRemarks(rs.getString("REMARKS")); col.setOrdinalPosition(rs.getInt("ORDINAL_POSITION")); col.setDefaultValue(rs.getString("COLUMN_DEF")); columns.add(col); } rs.close(); return columns; }在达梦下,getColumns 返回的 IS_AUTOINCREMENT 列也支持,它返回 YES 或 NO,可以用来判断自增字段。不过有些版本的驱动在这列取值上不稳定,更严谨的做法是拿系统视图 ALL_TAB_COLUMNS 中 IDENTITY 相关字段做补充确认。这就是典型的“通用代码 + 方言补偿”配合,通用主干保持干净,方言细节藏在补偿分支里。
主键读取:
public PrimaryKeyInfo getPrimaryKey(Connection conn, String schemaPattern, String tableName) throws SQLException { PrimaryKeyInfo pk = new PrimaryKeyInfo(); List<String> pkColumns = new ArrayList<>(); DatabaseMetaData metaData = conn.getMetaData(); ResultSet rs = metaData.getPrimaryKeys(conn.getCatalog(), schemaPattern, tableName); while (rs.next()) { pk.setPkName(rs.getString("PK_NAME")); pkColumns.add(rs.getString("COLUMN_NAME")); // KEY_SEQ 表示联合主键中的顺序,必须记录起来之后排序 } rs.close(); pk.setColumns(pkColumns); return pk; }主键这里有个容易被忽略的问题:返回的 ResultSet 不保证按 KEY_SEQ 排序。你需要先把 KEY_SEQ 一起读进内存,然后对列排序,否则联合主键的列顺序是随机的。我后面实操时真遇上了,第一次生成的 DDL 里联合主键顺序是反的。
2.3 类型映射:先判 JDBC 类型码,再看原生类型
通用代码搭起来之后,马上会遇到第一个跨库不一致点:类型映射。我建议用一个独立方法,把 JDBC 类型码转换成语义明确的逻辑类型:
public static String mapJdbcType(int jdbcType, String typeName, int size, int decimalDigits) { switch (jdbcType) { case Types.INTEGER: case Types.SMALLINT: case Types.TINYINT: return "int"; case Types.BIGINT: return "long"; case Types.VARCHAR: case Types.NVARCHAR: return "String"; case Types.DECIMAL: case Types.NUMERIC: return "decimal"; case Types.DATE: return "Date"; case Types.TIMESTAMP: return "Timestamp"; case Types.CLOB: return "text"; case Types.BLOB: return "binary"; default: return typeName; } }关键点在顺序和优先级。先看 JDBC 类型码,再看原生类型名。达梦里 DECIMAL(10,2) 的 JDBC 类型码是 Types.DECIMAL,NUMBER 类型有时候返回 Types.NUMERIC,有时候返回 Types.DECIMAL,两个 case 都要处理,否则代码生成会漏分支。
注释读取也有一个细节:JDBC 元数据接口的 REMARKS 字段,在达梦上能返回通过 COMMENT ON 语句写入的备注,建表时指定了字段注释也能正常返回。如果你需要的不只是简单的字段注释,而是表级注释甚至更复杂的说明,getTables 的 REMARKS 可能不够用。我当时的做法是:表级注释先用 REMARKS,信息不全时再查 ALL_TAB_COMMENTS 做一轮补偿。补偿查询务必限定在某个 schema 下,否则多用户模式下会查到别人表里的注释。
2.4 大小写策略:达梦里最容易被忽略的问题
在达梦上,表名和模式名的匹配规则,与建表时的写法直接相关。默认情况下,不带双引号的名称会被转成大写存储。所以,库里的表名通过 getTables 返回的通常是 BUY_ORDER_DETAIL 这种全大写。如果你的应用层习惯用驼峰命名 buyOrderDetail,直接拿这个名字做 pattern 去匹配,是匹配不到的。
我在设计通用代码时加了一个策略开关:是否对入参表名做统一转大写处理。针对达梦,默认开启“无引号名称转大写”的预处理;而在 MySQL 这类存储时保留大小写的库上,这个开关必须关闭。实现不复杂,在调用 getTables 和 getColumns 之前做一次规范化:
private String normalizeName(String name, boolean upperCaseStrategy) { if (name == null) return null; if (upperCaseStrategy) { return name.toUpperCase(); } return name; }这里还有更深一层的问题:如果用户建表时用了双引号,比如 create table "BuyOrderDetail",存储名称就是混合大小写,直接转大写反而匹配不上。所以更稳的做法是:先查一遍表清单,把实际表名集合加载到内存,应用层再做一次不区分大小写的匹配,找到真实表名后再传给 getColumns。虽然多查询一次,但换来了稳定。
这个方案通用性最好。我最终保留了两层匹配逻辑:第一层,用规范后的名字直接查;第二层,查不到时把当前 schema 下的表清单全量拉出来,做不区分大小写匹配,找到真实表名后再去取列。这段逻辑对上层完全透明,在达梦上靠它救了不少场景。
3. 实操过程:在达梦上完整跑通这套代码
3.1 步骤一:驱动、连接与版本确认
达梦的 JDBC 驱动引用不复杂:驱动类是 dm.jdbc.driver.DmDriver,连接 URL 形如 jdbc:dm://192.168.x.x:5236/数据库名。驱动包通常是 DmJdbcDriver.jar,不同版本内部类名基本稳定。
连接参数里第一个要关注的坑是字符集。有的部署环境客户端字符集和数据库服务端不一致,会导致元数据查询返回的字符串出现乱码。建议在连接串里显式指定字符集设置,或者通过应用侧的连接配置来控制。具体写法依驱动版本略有差异,稳妥的做法是先看官方连接参数说明,确认支持哪些字符集参数,再配置到连接池里。
初次接入时,强烈建议先写一个独立测试类,把数据库版本和驱动版本打印出来:
DatabaseMetaData md = conn.getMetaData(); System.out.println(md.getDatabaseProductName()); System.out.println(md.getDatabaseProductVersion()); System.out.println(md.getDriverName()); System.out.println(md.getDriverVersion());这一步值得做。它会直接告诉你驱动对应的兼容行为和版本,后面遇到怪问题时,版本信息是排查的重要依据。
3.2 步骤二:把表清单打印出来
用前面的 listTables 方法在达梦实例上跑一次。我的测试过程是:先用当前登录用户默认 schema 查询,能正常返回一批表;接着切换到另一个业务用户模式查询,这时需要把 schemaPattern 参数传成对应的大写模式名,否则结果为空。
输出样例大致是下面这种形式:
TABLE_SCHEMA=DMUSER1, TABLE_NAME=BUY_ORDER_DETAIL, TYPE=TABLE, REMARKS=订单明细表 TABLE_SCHEMA=DMUSER1, TABLE_NAME=PRODUCT_CATEGORY, TYPE=VIEW, REMARKS=商品分类视图如果 REMARKS 返回空,先别急着怀疑代码。到数据库客户端里执行一句:
SELECT * FROM ALL_TAB_COMMENTS WHERE TABLE_NAME = 'BUY_ORDER_DETAIL';看看能不能查到表注释。如果这里也是空,说明表本身就没写注释,代码逻辑再对也捞不到内容。
3.3 步骤三:读取列信息、主键、默认值与自增
列信息是整个模块的核心。测试时我特意找了两张表:一张是普通流水表,字段有十来个,类型覆盖 DATE、VARCHAR2、DECIMAL、NUMBER、CLOB;另一张是带联合主键、带自增列、带外键的配置表。
第一张表遍历结果非常正常,类型码、长度、小数位数都按预期返回。第二张表的联合主键出了问题:getPrimaryKeys 返回的主键列顺序是乱的,KEY_SEQ 有 1、2,也有 2、1。我在代码里补上排序逻辑后解决。
自增列的判断更值得记录。同一张自增表里,自增列在 getColumns 中返回的 IS_AUTOINCREMENT 字段,在某些达梦版本里是 YES,某些版本里是空。后来我用这种备用 SQL 做补偿:
SELECT COLUMN_NAME FROM ALL_TAB_IDENTITY_COLS WHERE OWNER = UPPER(?) AND TABLE_NAME = UPPER(?)这是很典型的思路:当 JDBC 元数据拿不到 100% 准确的细节时,用目标数据库的系统视图做定向补偿,补偿代码用配置开关隔离,尽量不让通用主干混入方言逻辑。
3.4 步骤四:生成一份可执行的 DDL 语句
读完元数据后,最直接的验证方法是把它反向生成建表语句。这也是判断代码是否“真正读对了”的硬标准。我写了一个简易生成器,对每个字段拼出名称、类型、长度、默认值、注释,最后拼主键约束。
生成过程中遇到两个达梦专属问题。
第一个是类型长度拼接。VARCHAR 类型需要长度,NUMBER 类型需要精度和小数位,但 CLOB、BLOB、DATE 这些类型不需要。如果统一模板处理,就会出现 date(0)、clob(0) 这种错误。解决方式是在拼 SQL 时判断 dataType:
if (col.getDataType() == Types.VARCHAR || col.getDataType() == Types.NVARCHAR) { sb.append(col.getTypeName()).append("(").append(col.getColumnSize()).append(")"); } else if (col.getDataType() == Types.DECIMAL || col.getDataType() == Types.NUMERIC) { if (col.getDecimalDigits() > 0) { sb.append(col.getTypeName()).append("(") .append(col.getColumnSize()).append(",") .append(col.getDecimalDigits()).append(")"); } else { sb.append(col.getTypeName()).append("(").append(col.getColumnSize()).append(")"); } } else { sb.append(col.getTypeName()); }第二个是表名大小写。建表语句如果没加双引号,达梦会直接把表名转大写,但如果原表是用双引号建的混合大小写表名,生成的 DDL 里去掉了引号,就会导致重建出来的表名和原表不一致。所以我生成的 DDL 对表名、字段名默认都加双引号。这个做法验证下来最稳,代价是数据库里所有对象名称都会被严格固定成引号内的大小写。
4. 常见问题与坑位总结
4.1 常见问题速查表
我把实际操作中最常撞上的问题整理成了一张速查表,后面再挑几个展开细说。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| getTables 返回空结果 | schema 传错大小写,或 catalog 传了干扰值 | 将 catalog 置空,schema 用大写模式名重试 |
| 表清单能查到,但 getColumns 返回空 | 表名大小写不一致,实际存储名称与传入 pattern 不同 | 拉取真实表名做不区分大小写匹配后再查询 |
| 字段注释始终为空 | 表本身没写 COMMENT,或驱动版本问题 | 用系统视图补偿查询,确认源头有值 |
| 联合主键顺序错乱 | 驱动返回顺序不稳定,KEY_SEQ 需要手工排序 | 读取 KEY_SEQ 字段,在内存中按顺序排序 |
| NUMBER 类型映射异常 | 驱动返回可能是 NUMERIC,也可能是 DECIMAL | 两个 case 都要处理,以 dataType 优先判断 |
| CLOB/BLOB 字段拼 DDL 出错 | 通用模板硬拼长度参数 | 按类型分组判断,特殊类型不拼接长度 |
| 自增标志在元数据中为空 | 驱动未完整实现该列 | 查询系统视图补偿,用属性开关控制启用 |
4.2 谈几个实际踩过的坑
第一个坑与模式有关。刚开始我按常规写法,把数据库名传给 catalog,用户名传给 schema,结果 getTables 返回 0 行,排查了很久。后来看驱动日志才发现,达梦 JDBC 驱动内部对 catalog 的理解和 MySQL 不完全一样。最后把 catalog 去掉,只保留 schema,结果才对。
第二个坑是注释。有个表的注释在客户端能看到,代码里读到却是 null。一开始以为是驱动解析问题,后来才发现是补偿查询时没有加 OWNER 条件,结果拿到的是另一个同名表的信息。这个经历让我学会了补偿 SQL 里必须显式限定模式,绝不能偷懒。
第三个坑是生成 DDL 时表名错乱。原因是原设计里 DDL 没有加引号,而原表是带引号的混合大小写表名,重建之后全变成大写。后来统一给 DDL 里的表名、字段名加双引号,问题才消失。
第四个坑不算复杂但很有代表性:getColumns 返回的 COLUMN_SIZE,对 NUMBER 类型字段返回的是精度还是最大字节数,不同版本驱动之间并不完全一致。如果你的生成逻辑对后者的依赖很强,建议在接正式库前,先建一张包含典型类型的测试表,把返回结果和客户端里的定义逐项比对一遍。这一步能提前发现驱动的“特殊解释”,省得以后在真实环境里反复折腾。
4.3 通用代码的边界与后续扩展
在达梦上这套代码能稳定跑起来,但我也要泼一点冷水。通用代码的优势是覆盖面广,劣势是每个数据库的特性拿不全。可以用补偿机制去补齐,但补偿补到一定程度后,代码会越来越难维护,需要有意识地做取舍。
我的经验是:如果只做表结构展示、字段清单导出、反向生成基础 DDL,JDBC 元数据接口完全够用,不需要碰系统表。但如果目标是生成精确到分区策略、表空间、索引结构、存储参数、列加密属性这类深水区内容,建议额外写一个达梦专属的方言模块,用系统表和系统视图作为数据源。通用代码与方言补偿代码分层存在,模块边界清晰,未来接更多数据库时,只需要替换方言模块。
我还见过一种延伸做法:把通用元数据读取做成命令行小工具,输入连接信息,输出 JSON 或 Markdown 格式的表结构文档。这个方向很适合做内部的数据字典导出工具。后续如果要继续扩展,可以把索引、约束、触发器也逐步纳入模型,但前提是在达梦上把这些信息的元数据行为先验证清楚。
最后说一点个人体会。通用代码这种东西,“通用”是相对而言的;我在达梦上验证通过,不代表换一台环境、换一个驱动版本就万事大吉。真正要在项目里落地,先把数据库版本、驱动版本、用户模式和表命名规范这四样固定下来,在一个稳定的环境基线上跑完整套流程,然后才开始接其他库。做过一遍你就能明白,所谓通用,不是代码本身没有差异分支,而是差异设计在了它该待的地方。