ChatGPT、Java 8 文档、MySQL都说 JDBC 没必要 `Class.forName()`,结果报错了……

Tomcat部署WAR应用时找不到数据库驱动的问题及解决方案
文章探讨了在独立Tomcat环境中部署WAR应用时遇到的找不到数据库驱动问题,分析了由于Tomcat启动过程和JDBC驱动加载顺序导致的问题根本原因。提供了三种解决方案,包括关闭Tomcat的driverManagerProtection、在Bean中指定驱动类初始化顺序以及在创建数据源前调用Class.forName()。

前段时间,有同学在使用 Apache ShardingSphere-JDBC 时遇到报错 java.sql.SQLException: No suitable driver。经过调查,发现这个问题和使用独立 Tomcat 部署应用有关。

相信做 Java 的同学对以下这段代码都不陌生。JDBC 获取一个数据库连接,然后使用这个连接做增删改查操作。

Class.forName("com.mysql.jdbc.Driver");
Connection conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306");

自 Java 引入 SPI 机制后,JDBC 的驱动可以注册为 SPI 的实现类,一般情况无须再通过 Class.forName 加载。但是,有些场景

探究独立 Tomcat 报错 java.sql.SQLException: No suitable driver 的根本原因

回顾 Tomcat 部署 WAR 应用报错找不到数据库驱动的问题

经历过 Tomcat 部署 WAR 包的开发者,可能多少都有遇到过,明明项目的 lib 目录下已经有了数据库驱动,但是应用却仍然报错 No suitable driver

getConnection: no suitable driver found for jdbc:mysql://127.0.0.1:3306
java.sql.SQLException: No suitable driver found for jdbc:mysql://127.0.0.1:3306
        at java.sql.DriverManager.getConnection(DriverManager.java:689)
        at java.sql.DriverManager.getConnection(DriverManager.java:247)
        at icu.wwj.hello.tomcat.driverdemo.HelloServlet.doGet(HelloServlet.java:18)

当然,去网上查一下会有很多解决方法的搜索结果,主要都是:

  • 确保已经添加了驱动;
  • 检查 JDBC URL 确保没有写错;
  • 把驱动放进 Tomcat 的 lib 目录(甚至有放进 JRE lib 目录的方法);
  • 获取连接前先调用 Class.forName("com.mysql.jdbc.Driver")
  • ……

至此,基本大多数场景找不到驱动的问题都能解决。但是,为什么把驱动放进 Tomcat 的 lib 目录,或者先调用 Class.forName() 能够解决问题?

看看 ChatGPT 提供的答案:
在这里插入图片描述

看起来与 Tomcat 的类加载器、部署机制有关系。

ChatGPT、Javadoc 和 MySQL 驱动都说没必要 Class.forName()

ChatGPT 的说法:
在这里插入图片描述

JDK 8 的 DriverManager 的 javadoc 中有这么一段话:

Applications no longer need to explicitly load JDBC drivers using Class.forName(). Existing programs which currently load JDBC drivers using Class.forName() will continue to work without modification.

意思就是应用程序不再需要通过 Class.forName() 显式加载 JDBC 驱动了,已有的程序这么干了也没问题。

在这里插入图片描述

追踪了一下历史,这段话在 GitHub OpenJDK 仓库的初始提交 中就已经存在了,所以是谁在什么时候写的也就不知道了。

就连 MySQL Connector/J 8.0.x 也输出警告说:驱动自动通过 SPI 注册,没有必要手动加载驱动类了

package com.mysql.jdbc;

import java.sql.SQLException;

/**
 * Backwards compatibility to support apps that call <code>Class.forName("com.mysql.jdbc.Driver");</code>.
 */
public class Driver extends com.mysql.cj.jdbc.Driver {
    public Driver() throws SQLException {
        super();
    }

    static {
        System.err.println("Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. "
                + "The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.");
    }
}

在 JDK 9,这套说辞被改掉了:

在这里插入图片描述

DriverManager 的驱动加载变成了懒加载,并使用线程上下文累加载器触发 SPI 机制加载驱动。应用程序加载和可用的驱动程序将取决于触发驱动程序初始化的线程的线程上下文类加载器。

虽然 SPI 加载驱动的时机做了调整,但一般情况下不需要显式调用 Class.forName 这点还是不变的。

实验

创建一个最小复现问题的 Demo

准备一个独立 Tomcat 并写一个简单的 Servlet,通过调试和源码分析为什么使用独立 Tomcat 会遇到 No suitable driver 的问题。

题外话:以前学习 Servlet 的时候都没有用过注解,都是在 web.xml 写一堆配置。使用自 Servlet 3.0 的注解,写一个 Servlet 的 hello, world 快多了,不需要像以前那样在 web.xml 写一堆配置了。

实现一个 HTTP GET 方法,在里面尝试获取数据库连接。

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;

@WebServlet(name = "driver", value = "/driver")
public class HelloServlet extends HttpServlet {
    
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("text/plain");
        DriverManager.setLogWriter(response.getWriter()); // 将 JDBC 日志直接输出到 HTTP 响应
        // try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { response.getWriter().println(e); }
        try (Connection c = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306", "root", "root")) {
            response.getWriter().println("Connected to MySQL " + c.getMetaData().getDatabaseProductVersion());
        } catch (Exception ex) {
            response.getWriter().println();
        }
    }
}

不调用 Class.forName("com.mysql.cj.jdbc.Driver")

考虑到不同版本 Java 的 DriverManager 存在差异,尝试过以下版本,均发生同样的错误:

  • Java 8
  • Java 17
  • Java 20(本文编写时 Java 已发布的最新版本)

Java 8 输出结果如下,Java 17 与 Java 20 的输出除了 DriverManager.java 的行数不一样外,其他完全一样。

$ curl -s 127.0.0.1:8080/driver

DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306")
getConnection: no suitable driver found for jdbc:mysql://127.0.0.1:3306
java.sql.SQLException: No suitable driver found for jdbc:mysql://127.0.0.1:3306
	at java.sql.DriverManager.getConnection(DriverManager.java:689)
	at java.sql.DriverManager.getConnection(DriverManager.java:247)
	at icu.wwj.hello.tomcat.driverdemo.HelloServlet.doGet(HelloServlet.java:18)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:529)
# ... 省略 Tomcat 调用栈	
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:750)
SQLException: SQLState(08001)

调用 Class.forName("com.mysql.cj.jdbc.Driver")

数据库操作正常执行。

$ curl -s 127.0.0.1:8080/driver

registerDriver: com.mysql.cj.jdbc.Driver@547a2eb2
DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306")
    trying com.mysql.cj.jdbc.Driver
getConnection returning com.mysql.cj.jdbc.Driver
Connected to MySQL 5.7.36

接下来笔者将探究问题的根本原因。

为什么找不到驱动?原因很简单

Java 8 源码

本节源码选自 JDK Azul Zulu Community 1.8.0_372

DriverManager 在 static 方法中通过系统变量、配置文件、SPI 加载 JDBC 驱动。

java.sql.DriverManager 源码节选

public class DriverManager {

    // List of registered JDBC drivers
    private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();

// 省略部分代码
	
    /**
     * Load the initial JDBC drivers by checking the System property
     * jdbc.properties and then use the {@code ServiceLoader} mechanism
     */
    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }
// 省略其余代码

打个断点调试发现,loadInitialDrivers() 方法执行完后,registeredDrivers 却是空的。查看代码调用栈,发现目前正处于 Tomcat 的启动阶段。MySQL JDBC 驱动在应用 WAR 包内,而应用的 WAR 包是在 Tomcat 启动完成后才开始加载的。

在这里插入图片描述

也就是,Tomcat 正在启动的时候就已经初始化了类 DriverManager,但此时 WAR 包还没有部署,所以 DriverManager 通过 SPI 加载不到任何驱动

Java 17 源码

Java 17 的 DriverManager 已经不在 static 代码块中调用 SPI 加载 JDBC 驱动类了,但问题的表现与 Java 8 却是一致的。调试发现,问题其实还是与 Tomcat 有关。
在这里插入图片描述
Tomcat 的 JreMemoryLeakPreventionListener.java 会调用一次 DriverManager.getDrivers() 方法,正是这个方法调用触发了 DriverManager 使用 SPI 加载驱动。

在这里插入图片描述

虽然 DriverManager 通过 SPI 加载驱动的时机变化了,但加载还是只会进行一次。所以后续通过 WAR 包部署应用,DriverManager 不会再通过 SPI 加载驱动了。

结论

  • java.sql.DriverManager 只会调用一次 SPI 加载 JDBC 驱动;
  • Tomcat 在启动时,部署 WAR 包应用前,调用了 DriverManager 的方法,触发了 SPI 加载机制;

于是,WAR 包中的 JDBC 驱动错过了 SPI JDBC 驱动加载,驱动无法自动注册。

解决方案

方式一:关闭 Tomcat driverManagerProtection

找到 Tomcat 目录下的 conf/server.xml 文件,修改以下内容:

 <Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />

关闭 driverManagerProtection:

<Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" driverManagerProtection="false" />

参考:https://github.com/apache/tomcat/blob/a25cb7910d6622e7b4e1507cfbf2600dacf350d3/webapps/docs/config/listeners.xml#L224

方式二:在 Bean 中显式指定驱动类初始化顺序

增加 driver Bean 并确保其顺序在创建数据源之前,等同于调用代码 Class.forName

<bean id="h2Driver" class="org.h2.Driver" />

<bean id="yourDataSource" depends-on="h2Driver">

方式三:在创建数据源前调用 Class.forName

Class.forName("org.h2.Driver");
### 问题分析 `Class.forName("com.mysql.cj.jdbc.Driver")` 报错通常是因为 Java 运行时环境无法找到指定的类 `com.mysql.cj.jdbc.Driver`。这可能是由于以下原因导致的: - **MySQL 驱动未正确添加到项目中**:如果 MySQL Connector/J 的 JAR 文件未包含在项目的类路径中,运行时将无法加载驱动。 - **驱动类名与版本不匹配**:早期版本的 MySQL 驱动使用的是 `com.mysql.jdbc.Driver`,而新版本(8.0 及以上)使用的是 `com.mysql.cj.jdbc.Driver`[^2]。 - **类路径配置错误**:即使驱动程序已添加到项目中,但如果类路径配置错误,仍然会导致无法加载驱动。 ### 解决方案 #### 1. 确保 MySQL 驱动已正确添加到项目中 将 MySQL Connector/J 的 JAR 文件添加到项目的构建路径中。以下是具体步骤: - 如果使用 Eclipse: - 右键点击项目 -> Build Path -> Configure Build Path。 - 在 Libraries 标签下点击 "Add External JARs",然后选择下载的 MySQL Connector/J JAR 文件。 - 如果使用 Maven 项目: - 在 `pom.xml` 文件中添加以下依赖项: ```xml <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <!-- 请根据需要选择版本 --> </dependency> ``` #### 2. 检查驱动类名是否正确 确保代码中使用的驱动类名与 MySQL Connector/J 的版本相匹配。对于 MySQL 8.0 及以上版本,应使用 `com.mysql.cj.jdbc.Driver`[^2]。示例代码如下: ```java try { Class.forName("com.mysql.cj.jdbc.Driver"); System.out.println("Driver loaded successfully!"); } catch (ClassNotFoundException e) { System.err.println("Failed to load driver: " + e.getMessage()); } ``` #### 3. 检查字符串中是否存在多余空格 确保 `Class.forName` 方法中的字符串值有多余的空格。例如,以下代码可能导致问题: ```java String driver = " com.mysql.cj.jdbc.Driver "; // 注意开头和结尾的空格 Class.forName(driver.trim()); // 使用 trim() 方法去除空格 ``` 建议直接写为: ```java Class.forName("com.mysql.cj.jdbc.Driver"); ``` #### 4. 验证类路径配置 确保 MySQL Connector/J 的 JAR 文件已正确添加到类路径中。可以通过以下方式验证: - 在命令行中运行 Java 程序时,使用 `-cp` 参数指定类路径: ```bash java -cp ".;path_to_mysql_connector/mysql-connector-java-8.0.33.jar" YourMainClass ``` - 如果使用 IDE,请检查项目的构建路径设置,确保 JAR 文件已被正确添加。 #### 5. 示例完整代码 以下是一个完整的 JDBC 连接 MySQL 数据库的示例代码: ```java import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class MySQLConnectionExample { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/your_database_name"; String user = "your_username"; String password = "your_password"; try { // 加载驱动 Class.forName("com.mysql.cj.jdbc.Driver"); // 确保类名正确 System.out.println("Driver loaded successfully!"); // 建立连接 Connection connection = DriverManager.getConnection(url, user, password); System.out.println("Connected to the database!"); // 关闭连接 connection.close(); } catch (ClassNotFoundException e) { System.err.println("Failed to load driver: " + e.getMessage()); } catch (SQLException e) { System.err.println("Database connection failed: " + e.getMessage()); } } } ``` ### 注意事项 - 如果仍然报错,可以尝试重新下载 MySQL Connector/J,并确保使用的是最新版本[^3]。 - 确保数据库服务已启动,并且 URL、用户名和密码均正确。 --- ###
评论 3
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wuweijie@apache.org

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值