Chat2DB关于MySQL查询过程讲解 - 连接测试

Chat2DB version: 1.0.9

引言

以下摘自Chat2DB官网的一段介绍

Chat2DB 是一款有开源免费的多数据库客户端工具,支持windows、mac本地安装,也支持服务器端部署,web网页访问。和传统的数据库客户端软件Navicat、DBeaver 相比Chat2DB集成了AIGC的能力,能够将自然语言转换为SQL,也可以将SQL转换为自然语言,可以给出研发人员SQL的优化建议,极大的提升人员的效率,是AI时代数据库研发人员的利器,未来即使不懂SQL的运营业务也可以使用快速查询业务数据、生成报表能力。

本篇主要以集成MySQL查询为例,是如何实现Web SQL查询的。

添加MySQL

入口
querySQL01

配置数据源

querySQL02

连接数据源表单支持JDBC 5.0/8.0两个版本,我们可以配置MySQL连接地址,通过连接测试测试配置信息是否正确。
那是如何来区分 JDBC 5.0/8.0两个不同的版本的呢?以及MySQL连接信息又是如何持久化的呢

Rest API

http://127.0.0.1:10821/api/connection/datasource/pre_connect # POST 参数 {"ssh":{"use":"false","hostName":"","port":"","userName":"","localPort":"","password":""},"alias":"@localhost","host":"localhost","port":"3306","authentication":1,"user":"root","password":"12345678","database":"","url":"jdbc:mysql://localhost:3306/","jdbc":"8.0","extendInfo":[{"key":"zeroDateTimeBehavior","value":"convertToNull"}],"EnvType":"DAILY","type":"MYSQL"}

DataSourceController#preConnect()方法处理连接测试逻辑,在preConnect()方法中的dataSourceWebConverter,而它本身是一个抽象类,它使用了@Mapper(componentModel = "spring"),它是MapStruct库中的一个注解,是一个用于在Java Bean类型之间自动生成映射代码的库。它能够生成可在运行时实例化和使用的映射器对象,减少了手动编写字段到字段复制代码的需要。

注意默认是同名字段

DataSourceController#preConnect()

@RequestMapping("/datasource/pre_connect") public ActionResult preConnect(@RequestBody DataSourceTestRequest request) { DataSourcePreConnectParam param = dataSourceWebConverter.testRequest2param(request); return dataSourceService.preConnect(param); }

我们可以通过IDE的Diagrams插件可以对比下两个实体类字段名称是否一样。 其实这里处理方式也特别像 hutool的BeanUtil#copyProperties()方法,进行字段的赋值,避免了手动去set较多字段。
querySQL03

接下来我们看preConnect()方法。

@Override public ActionResult preConnect(DataSourcePreConnectParam param) { DataSourceTestParam testParam = dataSourceConverter.param2param(param); DataSourceConnect dataSourceConnect = JdbcUtils.testConnect(testParam.getUrl(), testParam.getHost(), testParam.getPort(), testParam.getUsername(), testParam.getPassword(), DbTypeEnum.getByName(testParam.getDbType()), param.getJdbc(), param.getSsh(), KeyValue.toMap(param.getExtendInfo())); if (BooleanUtils.isNotTrue(dataSourceConnect.getSuccess())) { return ActionResult.fail(dataSourceConnect.getMessage(), dataSourceConnect.getDescription()); } return ActionResult.isSuccess(); }

在对testParam做类型转换时,这里处理与上面dataSourceWebConverter.testRequest2param处理有稍许不同,dataSourceConverter#param2param()方法使用字段映射规则,示例如下:

@Mappings({ @Mapping(source = "type", target = "dbType"), @Mapping(source = "user", target = "username") }) public abstract DataSourceTestParam param2param( DataSourcePreConnectParam dataSourcePreConnectParam);

这意味着,在两个实体类字段不一样时,可通过定义字段的引射规则匹配并赋值。

现在我们回到JdbcUtils#testConnect(),它的形参与平时我们访问MySQL没啥区别,但在JDBC连接MySQL(5.0/8.0)不同版本时,驱动名称是会有不同,那Chat2DB又是如何处理根据param.getJdbc()使用不同mysql-connector的呢? 我们先看下其核心Connection对象的构建过程:

// 创建连接 connection = IDriverManager.getConnection(url, userName, password, DriverTypeEnum.getDriver(dbType, jdbc), properties);

DriverTypeEnum.getDriver(dbType, jdbc) 根据db类型以及jdbc版本返回了一个DriverTypeEnum枚举,内容如下:
querySQL04

  • DBTypeEnum: 用于标识DB类型
  • driverClass:DB连接驱动名称
  • jar: 驱动jar
  • jdbc: DB版本

此时到IDriverManager#getConnection()方法

public static Connection getConnection(String url, Properties info, DriverTypeEnum driverTypeEnum) throws SQLException { if (url == null) { throw new SQLException("The url cannot be null", "08001"); } DriverManager.println("DriverManager.getConnection(\"" + url + "\")"); SQLException reason = null; DriverEntry driverEntry = DRIVER_ENTRY_MAP.get(driverTypeEnum); if (driverEntry == null) { driverEntry = getJDBCDriver(driverTypeEnum); } try { Connection con = driverEntry.getDriver().connect(url, info); if (con != null) { DriverManager.println("getConnection returning " + driverEntry.getDriver().getClass().getName()); return con; } } catch (SQLException var7) { if (reason == null) { reason = var7; } } if (reason != null) { DriverManager.println("getConnection failed: " + reason); throw reason; } else { DriverManager.println("getConnection: no suitable driver found for " + url); throw new SQLException("No suitable driver found for " + url, "08001"); } }

通过driverTypeEnum枚举作为key,从DRIVER_ENTRY_MAP获取DriverEntry类型的对象,如果获取不到则生成一个并放入DRIVER_ENTRY_MAP中,接下来我们看DriverEntry对象是如何创建的?

private static DriverEntry getJDBCDriver(DriverTypeEnum driverTypeEnum) throws SQLException { synchronized (driverTypeEnum) { try { if (DRIVER_ENTRY_MAP.containsKey(driverTypeEnum)) { return DRIVER_ENTRY_MAP.get(driverTypeEnum); } ClassLoader cl = getClassLoader(driverTypeEnum); Driver driver = (Driver)cl.loadClass(driverTypeEnum.getDriverClass()).newInstance(); DriverEntry driverEntry = DriverEntry.builder().driverTypeEnum(driverTypeEnum).driver(driver).build(); DRIVER_ENTRY_MAP.put(driverTypeEnum, driverEntry); return driverEntry; } catch (Exception e) { log.error("getJDBCDriver error", e); throw new SQLException("getJDBCDriver error", "08001"); } } }

IDriverManager是如何加载MySQL不同JDBC

上面我们已经分析到IDriverManager#getJDBCDriver()方法负责加载JDBC驱动类。 如果要动态加载class,需要做以下几点:

  • 下载对应版本的MySQL的mysql-connector jar
  • 扫描jar文件
  • 创建类加载器
  • 加载指定类名并创建实例对象

IDriverManager#getClassLoader()

public static ClassLoader getClassLoader(DriverTypeEnum driverTypeEnum) throws MalformedURLException { String jarPath = driverTypeEnum.getJar(); if (CLASS_LOADER_MAP.containsKey(jarPath)) { return CLASS_LOADER_MAP.get(jarPath); } else { synchronized (jarPath) { if (CLASS_LOADER_MAP.containsKey(jarPath)) { return CLASS_LOADER_MAP.get(jarPath); } String[] jarPaths = jarPath.split(","); URL[] urls = new URL[jarPaths.length]; for (int i = 0; i < jarPaths.length; i++) { File driverFile = new File(getFullPath(jarPaths[i])); urls[i] = driverFile.toURI().toURL(); } //urls[jarPaths.length] = new File(JdbcJarUtils.getFullPath("HikariCP-4.0.3.jar")).toURI().toURL(); URLClassLoader cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader()); log.info("ClassLoader class:{}", cl.hashCode()); log.info("ClassLoader URLs:{}", JSON.toJSONString(cl.getURLs())); try { cl.loadClass(driverTypeEnum.getDriverClass()); } catch (Exception e) { //如果报错删除目录重试一次 for (int i = 0; i < jarPaths.length; i++) { File driverFile = new File(JdbcJarUtils.getNewFullPath(jarPaths[i])); urls[i] = driverFile.toURI().toURL(); } //urls[jarPaths.length] = new File(JdbcJarUtils.getFullPath("HikariCP-4.0.3.jar")).toURI().toURL(); cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader()); } CLASS_LOADER_MAP.put(jarPath, cl); return cl; } } }

获取jar路径,扫描jar文件,创建url类加载器并指定父加载器,再通过loadClass()加载到JVM中,最后再放入CLASS_LOADER_MAP.put(jarPath, cl); 缓存中。

这段 Java 代码创建了一个新的 URLClassLoader 实例。URLClassLoader 是 Java 类加载器(ClassLoader)的一个具体实现,它按照特定的 URL(统一资源定位符)列表来加载类和资源。
以下是代码的具体分析:

  • urls:这是一个 URL[] 数组,表示类加载器从中加载类和资源的 URL 列表。URL 可以引用本地文件系统上的文件,也可以引用网络上的资源。
  • ClassLoader.getSystemClassLoader():这是父类加载器(parent class loader)。每个类加载器(除了引导类加载器)在其创建时都有一个相关的父类加载器。当请求类加载器加载类或资源时,它首先将请求委托给其父类加载器,这是双亲委派模型的核心。
    通过创建新的 URLClassLoader 实例,你可以动态地将额外的路径(在这种情况下是 URL)添加到应用程序的类路径中,从而可以从那些路径加载类和资源。这在需要动态加载类,如插件系统中,非常有用。

这里就丢了一个疑惑,mysql-connector连接器的jar是什么下载的?

querySQL05

Chat2DB项目启动日志中看到上面图片中的log,我想这里也帮我们解惑了,当服务启动后首先会判断某个目录下DB连接驱动jar是否存在,不存在则下载,那接着探究它是如何实现的?

@Component @Slf4j public class JarDownloadTask implements CommandLineRunner { @Resource private AliDbhubProperties aliDbhubProperties; @Override public void run(String... args) throws Exception { DbhubContext.JDBC_JAR_DOWNLOAD_URL_LIST = aliDbhubProperties.getJdbcJarDownLoadUrls(); JdbcJarUtils.asyncDownload(aliDbhubProperties.getJdbcJarDownLoadUrls()); } }

JarDownloadTask实现了CommandLineRunner接口CommandLineRunner 是一个Spring Boot特性,它允许你在应用程序启动后运行一些特定的代码。你可以实现它来运行任何你需要在Spring Boot应用程序启动后执行的代码。

从JarDownloadTask代码可知,服务启动后读取application.yaml配置,拉取jar并下载到用户目录下

// 指定目录 private static final String PATH = System.getProperty("user.home") + File.separator + ".chat2db" + File.separator + "jdbc-lib" + File.separator;

配置信息
querySQL06

总体流程
querySQL07

总结

本篇blog的技术实现难点在于类的动态加载,这里特别提醒,必须要将已经加载的class放入缓存中,避免JVM频繁加载可能导致内存溢出问题。

在Java中,频繁的动态加载类可能导致Metaspace或者PermGen Space(取决于你使用的是Java 8+还是更早的版本)的内存溢出。

在Java 8之前,类元数据被存储在被称为PermGen Space的堆内存的一个特殊区域中。如果这个区域被填满,你将会得到一个java.lang.OutOfMemoryError: PermGen Space错误。

从Java 8开始,PermGen Space被移除并被Metaspace所取代,类的元数据现在被存储在Metaspace中。Metaspace位于本地内存(不是在Java虚拟机堆内存中),因此其最大值受限于你的系统内存。但是,仍然存在溢出的可能性。当Metaspace填满时,你会得到一个java.lang.OutOfMemoryError: Metaspace错误。

为什么动态加载类会导致这个问题?这是因为每当一个类被加载到Java虚拟机中时,关于这个类的元数据都会被存储在Metaspace或PermGen Space中。如果你在程序运行过程中动态加载大量的类,并且这些类在之后没有被卸载,那么这些类的元数据会持续占用内存,最终可能导致内存溢出。

解决这个问题的一个办法是增大Metaspace或PermGen Space的大小。你可以通过Java虚拟机的启动参数来实现,例如,-XX:MaxMetaspaceSize=512m或者-XX:MaxPermSize=512m。另外一个办法是确保你的应用程序在加载类之后,当类不再需要时可以正确地卸载它们。