Giới thiệu JDBC và JDBC Drivers
Xem thửMột dịch vụ báo cáo chạy ổn nhiều tháng với MySQL. Một ngày nhóm thêm H2 để chạy kiểm thử nhanh trong bộ nhớ, đóng ứng dụng thành một fat jar bằng maven-shade-plugin và đưa lên máy chủ. Từ lúc đó, lệnh khởi động báo SQLException: No suitable driver với URL jdbc:h2:mem:report. Máy lập trình viên chạy bằng IDE vẫn tốt, cả hai thư viện nằm trong jar, URL không có lỗi chính tả. Mất một buổi chiều mới tìm ra: hai thư viện driver cùng mang một file META-INF/services/java.sql.Driver, và công cụ đóng gói đã để file của thư viện này đè lên file của thư viện kia.
Sự cố đó chỉ hiểu được khi bạn biết JDBC thực sự là gì. JDBC không phải một thư viện bạn tải về; nó là một bản hợp đồng nằm sẵn trong JDK, còn thứ làm việc thật là driver của từng nhà cung cấp, được tìm thấy qua một cơ chế nạp động. Bài này mở hộp đen đó: kiến trúc các tầng, cách driver được nạp và được chọn, driver MySQL thật sự gửi gì lên máy chủ, và những hành vi của API khiến người mới viết code sai mà không biết.
Sau bài này, bạn có thể
- Vẽ được các tầng từ code ứng dụng tới máy chủ cơ sở dữ liệu và chỉ ra tầng nào nằm trong JDK, tầng nào do nhà cung cấp viết.
- Giải thích driver được đăng ký thế nào mà không cần
Class.forName, vàDriverManagerchọn driver theo tiêu chí nào. - Chẩn đoán lỗi
No suitable drivergồm cả trường hợp fat jar, và sửa đúng. - Dự đoán hành vi
PreparedStatementmặc định của MySQL: câu lệnh được gửi đi thế nào. - Dùng đúng
getIntvới cột có thể NULL, đóng tài nguyên đúng thứ tự, và đọc lớp ngoại lệ cùng SQLState để phân loại lỗi.
Chuẩn bị môi trường và dự án
Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, MySQL 8.4.9, Connector/J 26.7.0, H2 2.5.252). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 02-jdbc/01-gioi-thieu-jdbc, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.
Yêu cầu môi trường
| Thành phần | Phiên bản đã kiểm chứng | Ghi chú |
|---|---|---|
| JDK | 17 trở lên | java -version |
| Maven | 3.9 trở lên | mvn -v |
| Docker + Docker Compose | bất kỳ bản gần đây | MySQL cho demo và Testcontainers cho test |
| MySQL | 8.4 (image mysql:8.4) | |
| MySQL Connector/J | 26.7.0 | khai báo trong pom.xml |
| H2 | 2.5.252 | driver thứ hai, chỉ để so sánh việc chọn driver |
Cấu trúc
01-gioi-thieu-jdbc/
docker-compose.yml MySQL 8.4, database shop, user app
pom.xml thêm hai profile đóng fat jar: fatjar-broken, fatjar-fixed
src/main/java/dev/canhdominich/jdbc/intro/
DbConfig.java đọc URL, user, mật khẩu từ biến môi trường
Schema.java tạo bảng product và hai dòng mẫu
DriverDiscovery.java ServiceLoader, DriverManager.getDrivers, getDriver theo URL
WhatDriverSends.java bật general_log và in các câu lệnh server nhận được
ApiTour.java lớp cài đặt, DatabaseMetaData, NULL, đóng tài nguyên, SQLException
Khai báo thư viện: pom.xml
pom.xml
Khai báo dự án Maven Java 17 với driver MySQL và driver H2 làm hai thư viện chạy chương trình, cùng JUnit, Testcontainers và AssertJ cho test. Điểm đặc biệt là hai profile fatjar-broken và fatjar-fixed: cả hai dùng maven-shade-plugin để đóng mọi thư viện vào một tệp jar duy nhất, nhưng chỉ fatjar-fixed có ServicesResourceTransformer để gộp các file META-INF/services. Hai profile này tái hiện sự cố mất driver ở phần đầu bài.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.canhdominich.javabackend</groupId>
<artifactId>jdbc-gioi-thieu</artifactId>
<version>1.0.0</version>
<name>JDBC - Gioi thieu JDBC</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>dev.canhdominich.jdbc.intro.DriverDiscovery</main.class>
<mysql.connector.version>26.7.0</mysql.connector.version>
<h2.version>2.5.252</h2.version>
<junit.version>6.0.3</junit.version>
<testcontainers.version>2.0.5</testcontainers.version>
<assertj.version>3.27.7</assertj.version>
</properties>
<dependencies>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.connector.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<executable>java</executable>
<arguments>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>fatjar-broken</id>
<build>
<finalName>lab-broken</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>fatjar-fixed</id>
<build>
<finalName>lab-fixed</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
Cấu hình Docker Compose
docker-compose.yml
File này khởi động MySQL 8.4 bằng một lệnh. Nó tạo database shop, tài khoản ứng dụng app (mật khẩu app_pw) và tài khoản quản trị root (mật khẩu root_pw), mở cổng 3307 của máy bạn tới cổng 3306 trong container, và khai báo healthcheck bằng mysqladmin ping để docker compose ps báo healthy khi MySQL đã sẵn sàng nhận kết nối.
services:
mysql:
image: mysql:8.4
container_name: jdbc-lab-mysql
environment:
MYSQL_ROOT_PASSWORD: root_pw
MYSQL_DATABASE: shop
MYSQL_USER: app
MYSQL_PASSWORD: app_pw
ports:
- "${MYSQL_PORT:-3307}:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pw"]
interval: 3s
timeout: 3s
retries: 30
Khởi động và kiểm tra MySQL:
docker compose up -d
docker compose ps
Chờ cột STATUS hiện healthy. MySQL lắng nghe ở cổng 3307 của máy bạn; nếu bạn đang chạy một lab khác dùng cùng cổng, chạy docker compose down ở lab đó trước. Biến môi trường mặc định của chương trình: JDBC_URL=jdbc:mysql://localhost:3307/shop, DB_USER=app, DB_PASSWORD=app_pw.
JDBC là hợp đồng, driver là người thực hiện
Cách đọc hình: hàng trên là đường đi của một lời gọi, từ trái sang phải. Code của bạn chỉ chạm tới các interface Connection, PreparedStatement, ResultSet; chúng nằm trong module java.sql của JDK (module này xuất bản hai package là java.sql và javax.sql). DriverManager nhận URL, chọn driver, và driver mới là bên biết nói giao thức riêng của từng máy chủ qua TCP. Hàng dưới là đường đăng ký: jar của driver khai báo mình trong META-INF/services/java.sql.Driver, ServiceLoader đọc file đó và đưa driver cho DriverManager.
Hệ quả thực tế của thiết kế này:
- Code không phụ thuộc nhà cung cấp. Đổi cơ sở dữ liệu là đổi jar driver và URL, còn câu SQL thì có thể vẫn phải sửa vì phương ngữ SQL khác nhau.
- Cùng một interface nhưng lớp cài đặt do driver quyết định. Chương trình
ApiTourngay bên dưới in ra tên lớp thật:Connectionlàcom.mysql.cj.jdbc.ConnectionImpl,PreparedStatementlàcom.mysql.cj.jdbc.ClientPreparedStatement,ResultSetlàcom.mysql.cj.jdbc.result.ResultSetImpl. Cái tênClientPreparedStatementsẽ có ý nghĩa ở phần sau của bài. - Driver báo phiên bản đặc tả mà nó cài đặt qua
DatabaseMetaData. Với Connector/J 26.7.0,getJDBCMajorVersion()vàgetJDBCMinorVersion()trả về 4 và 2, tức JDBC 4.2.
Chương trình ApiTour: nhìn tận mắt các lớp cài đặt
Để mọi điều bài nói đều kiểm chứng được, bài dùng một chương trình main tên ApiTour. Nó mở một kết nối tới MySQL, in tên lớp cài đặt của Connection, PreparedStatement, ResultSet, đọc thông tin driver qua DatabaseMetaData, rồi lần lượt thử ba hành vi của API (cột NULL, đóng tài nguyên, lỗi SQL) sẽ được phân tích ở phần sau của bài. Nó cần hai lớp nhỏ phụ trợ.
DbConfig giữ thông tin kết nối và đọc từ biến môi trường, để mật khẩu không nằm trong code:
src/main/java/dev/canhdominich/jdbc/intro/DbConfig.java
package dev.canhdominich.jdbc.intro;
public record DbConfig(String url, String user, String password) {
public static DbConfig fromEnvironment() {
return new DbConfig(
env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"),
env("DB_USER", "app"),
env("DB_PASSWORD", "app_pw"));
}
public DbConfig withUrl(String newUrl) {
return new DbConfig(newUrl, user, password);
}
public DbConfig withPassword(String newPassword) {
return new DbConfig(url, user, newPassword);
}
private static String env(String name, String fallback) {
String value = System.getenv(name);
return value == null || value.isBlank() ? fallback : value;
}
}
Schema tạo bảng product với hai dòng mẫu, trong đó dòng Mouse có cột stock là NULL:
src/main/java/dev/canhdominich/jdbc/intro/Schema.java
package dev.canhdominich.jdbc.intro;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
final class Schema {
private Schema() {
}
static void reset(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("DROP TABLE IF EXISTS product");
statement.execute("""
CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
sku VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NULL
)""");
statement.execute("INSERT INTO product(sku, name, price, stock) VALUES ('KB-01', 'Keyboard', 49.90, 12), ('MS-02', 'Mouse', 19.50, NULL)");
}
}
}
Và đây là ApiTour:
src/main/java/dev/canhdominich/jdbc/intro/ApiTour.java
package dev.canhdominich.jdbc.intro;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public final class ApiTour {
private ApiTour() {
}
public static void main(String[] args) throws SQLException {
DbConfig config = DbConfig.fromEnvironment();
try (Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password())) {
Schema.reset(connection);
implementations(connection);
capabilities(connection);
nullHandling(connection);
closeCascade(config);
exceptionAnatomy(connection);
}
}
static void implementations(Connection connection) throws SQLException {
System.out.println("-- implementations");
System.out.println("Connection " + connection.getClass().getName());
try (PreparedStatement ps = connection.prepareStatement("SELECT id, price FROM product")) {
System.out.println("PreparedStatement " + ps.getClass().getName());
try (ResultSet rs = ps.executeQuery()) {
System.out.println("ResultSet " + rs.getClass().getName());
System.out.println("ResultSetMetaData " + rs.getMetaData().getClass().getName());
System.out.println("price column type " + rs.getMetaData().getColumnTypeName(2) + " -> " + rs.getMetaData().getColumnClassName(2));
}
}
}
static void capabilities(Connection connection) throws SQLException {
DatabaseMetaData meta = connection.getMetaData();
System.out.println("-- capabilities");
System.out.println("product " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion());
System.out.println("driver " + meta.getDriverName() + " " + meta.getDriverVersion());
System.out.println("JDBC version " + meta.getJDBCMajorVersion() + "." + meta.getJDBCMinorVersion());
System.out.println("transactions " + meta.supportsTransactions());
System.out.println("batch updates " + meta.supportsBatchUpdates());
System.out.println("isolation " + connection.getTransactionIsolation() + " (REPEATABLE_READ=" + Connection.TRANSACTION_REPEATABLE_READ + ")");
System.out.println("autoCommit " + connection.getAutoCommit());
}
static void nullHandling(Connection connection) throws SQLException {
System.out.println("-- null handling for the Mouse row (stock is NULL)");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT stock FROM product WHERE sku = 'MS-02'")) {
rs.next();
int asInt = rs.getInt("stock");
System.out.println("getInt = " + asInt + ", wasNull = " + rs.wasNull());
System.out.println("getObject = " + rs.getObject("stock"));
System.out.println("getObject(Integer.class) = " + rs.getObject("stock", Integer.class));
}
}
static void closeCascade(DbConfig config) throws SQLException {
System.out.println("-- closing the connection closes what it created");
Connection connection = DriverManager.getConnection(config.url(), config.user(), config.password());
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT 1");
connection.close();
System.out.println("statement.isClosed = " + statement.isClosed());
System.out.println("resultSet.isClosed = " + rs.isClosed());
}
static void exceptionAnatomy(Connection connection) throws SQLException {
System.out.println("-- anatomy of an SQLException");
try (PreparedStatement ps = connection.prepareStatement("INSERT INTO product(sku, name, price) VALUES (?, ?, ?)")) {
ps.setString(1, "KB-01");
ps.setString(2, "Duplicate keyboard");
ps.setBigDecimal(3, new java.math.BigDecimal("10.00"));
ps.executeUpdate();
} catch (SQLException e) {
System.out.println("class = " + e.getClass().getName());
System.out.println("superclass = " + e.getClass().getSuperclass().getName());
System.out.println("SQLState = " + e.getSQLState());
System.out.println("errorCode = " + e.getErrorCode());
System.out.println("message = " + e.getMessage());
}
for (String sql : new String[]{"SELECT * FROM missing_table", "SELEC 1"}) {
try (Statement statement = connection.createStatement()) {
statement.executeQuery(sql);
} catch (SQLException e) {
System.out.println(sql + " -> " + e.getClass().getSimpleName() + " SQLState=" + e.getSQLState() + " errorCode=" + e.getErrorCode() + " message=" + e.getMessage());
}
}
}
}
Sau khi chạy MySQL bằng Docker Compose (xem mục Chuẩn bị môi trường và dự án ở đầu bài), chạy chương trình bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.jdbc.intro.ApiTour cho kết quả:
-- implementations
Connection com.mysql.cj.jdbc.ConnectionImpl
PreparedStatement com.mysql.cj.jdbc.ClientPreparedStatement
ResultSet com.mysql.cj.jdbc.result.ResultSetImpl
ResultSetMetaData com.mysql.cj.jdbc.result.ResultSetMetaData
price column type DECIMAL -> java.math.BigDecimal
-- capabilities
product MySQL 8.4.9
driver MySQL Connector/J mysql-connector-j-26.7.0 (Revision: 559f62df01d4f618440da174ff45ba87f7b3b2a8)
JDBC version 4.2
transactions true
batch updates true
isolation 4 (REPEATABLE_READ=4)
autoCommit true
-- null handling for the Mouse row (stock is NULL)
getInt = 0, wasNull = true
getObject = null
getObject(Integer.class) = null
-- closing the connection closes what it created
statement.isClosed = true
resultSet.isClosed = true
-- anatomy of an SQLException
class = java.sql.SQLIntegrityConstraintViolationException
superclass = java.sql.SQLNonTransientException
SQLState = 23000
errorCode = 1062
message = Duplicate entry 'KB-01' for key 'product.sku'
SELECT * FROM missing_table -> SQLSyntaxErrorException SQLState=42S02 errorCode=1146 message=Table 'shop.missing_table' doesn't exist
SELEC 1 -> SQLException SQLState=S1009 errorCode=0 message=Statement.executeQuery() cannot issue statements that do not produce result sets.
Các phần của kết quả được giải thích từng chỗ ở các mục sau: tên lớp ở trên, ba hành vi dễ sai ở mục "Ba hành vi của API khiến người mới viết sai", mức cô lập và autoCommit ở mục "Thông tin mặc định của phiên".
Bốn loại driver, và chỉ một loại còn quan trọng
| Loại | Tên | Cách hoạt động | Hiện nay |
|---|---|---|---|
| Type 1 | JDBC-ODBC bridge | Chuyển lời gọi sang ODBC của hệ điều hành | Đã bị gỡ khỏi JDK từ Java 8 |
| Type 2 | Native API | Gọi thư viện native do nhà cung cấp phát hành | Hiếm, gắn với hệ điều hành |
| Type 3 | Network protocol | Nói chuyện với một máy chủ trung gian | Gần như không còn |
| Type 4 | Thin driver | Thuần Java, tự nói giao thức mạng của cơ sở dữ liệu | Chuẩn thực tế: mysql-connector-j, driver PostgreSQL, H2 |
Khi tài liệu cũ dành nhiều trang cho bốn loại, bạn chỉ cần nhớ ý nghĩa thực tế của loại 4: driver là một file jar thuần Java, không cài thêm gì lên máy chủ ứng dụng, nên việc triển khai chỉ là đưa jar vào classpath.
Driver được nạp thế nào: không còn Class.forName
Tài liệu cũ dạy dòng Class.forName("com.mysql.jdbc.Driver"). Từ JDBC 4.0 nó thừa. Để kiểm chứng, bài dùng chương trình DriverDiscovery. Nó liệt kê driver theo hai cách (ServiceLoader và DriverManager.drivers()), rồi với bốn URL khác nhau hỏi DriverManager.getDriver(url) xem driver nào được chọn, hoặc lỗi gì. Trong chương trình không có Class.forName nào, và classpath của dự án có hai driver: MySQL và H2.
src/main/java/dev/canhdominich/jdbc/intro/DriverDiscovery.java
package dev.canhdominich.jdbc.intro;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ServiceLoader;
public final class DriverDiscovery {
private static final String[] URLS = {
"jdbc:mysql://localhost:3307/shop",
"jdbc:h2:mem:lab",
"jdbc:postgresql://localhost:5432/shop",
"mysql://localhost:3307/shop"
};
private DriverDiscovery() {
}
public static void main(String[] args) {
System.out.println("ServiceLoader finds:");
for (Driver driver : ServiceLoader.load(Driver.class)) {
System.out.println(" " + driver.getClass().getName());
}
System.out.println("DriverManager.getDrivers():");
DriverManager.drivers().forEach(driver -> System.out.println(" " + driver.getClass().getName()));
for (String url : URLS) {
System.out.println("getDriver(" + url + ")");
System.out.println(" " + describe(url));
}
}
static String describe(String url) {
try {
return DriverManager.getDriver(url).getClass().getName();
} catch (SQLException e) {
return e.getClass().getSimpleName() + " SQLState=" + e.getSQLState() + " message=" + e.getMessage();
}
}
}
Chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.jdbc.intro.DriverDiscovery (không cần MySQL đang chạy, vì chương trình chỉ hỏi DriverManager chứ không mở kết nối). Phần đầu của kết quả:
ServiceLoader finds:
com.mysql.cj.jdbc.Driver
org.h2.Driver
DriverManager.getDrivers():
com.mysql.cj.jdbc.Driver
org.h2.Driver
Danh sách đến từ file META-INF/services/java.sql.Driver trong từng jar, mỗi dòng là một tên lớp. Khi DriverManager khởi tạo lần đầu, nó nạp tất cả bằng ServiceLoader. Đến lúc bạn gọi getConnection(url, user, password), nó hỏi từng driver xem có nhận URL này không (acceptsURL), driver đầu tiên nhận được sẽ mở kết nối.
Phần còn lại của kết quả là lời hỏi getDriver cho bốn URL, tổng hợp lại thành bảng:
| URL | Kết quả |
|---|---|
jdbc:mysql://localhost:3307/shop | com.mysql.cj.jdbc.Driver |
jdbc:h2:mem:lab | org.h2.Driver |
jdbc:postgresql://localhost:5432/shop | SQLException, SQLState 08001, No suitable driver (không có driver PostgreSQL trên classpath) |
mysql://localhost:3307/shop | SQLException, SQLState 08001, No suitable driver (thiếu tiền tố jdbc:) |
No suitable driver chỉ có hai nguyên nhân gốc: URL sai tiền tố, hoặc driver tương ứng không có mặt trong danh sách đã đăng ký. Nguyên nhân thứ hai có một dạng khó thấy, được tái hiện ngay dưới đây.
Sự cố fat jar: driver nằm trong jar mà vẫn không dùng được
Dự án có hai profile Maven (khai báo trong pom.xml, in đầy đủ ở mục Chuẩn bị môi trường và dự án ở đầu bài). Cả hai dùng maven-shade-plugin để đóng chương trình DriverDiscovery, driver MySQL và driver H2 vào một tệp jar duy nhất chạy được bằng java -cp, và khác nhau ở đúng một điểm. Profile fatjar-fixed có thêm đoạn cấu hình sau, còn fatjar-broken thì không:
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
Đóng gói và chạy:
mvn -q -DskipTests -Pfatjar-broken package
java -cp target/lab-broken.jar dev.canhdominich.jdbc.intro.DriverDiscovery
mvn -q -DskipTests -Pfatjar-fixed package
java -cp target/lab-fixed.jar dev.canhdominich.jdbc.intro.DriverDiscovery
Kết quả đo được:
| Jar | Nội dung META-INF/services/java.sql.Driver | ServiceLoader thấy | getDriver(jdbc:h2:mem:lab) |
|---|---|---|---|
lab-broken.jar | một dòng: com.mysql.cj.jdbc.Driver | chỉ MySQL | SQLException 08001, No suitable driver |
lab-fixed.jar | hai dòng: MySQL và H2 | cả hai | org.h2.Driver |
Lớp org.h2.Driver vẫn nằm trong lab-broken.jar, nhưng không ai đăng ký nó. Khi nhiều jar cùng chứa một file cùng đường dẫn, công cụ đóng gói mặc định giữ một bản và bỏ các bản còn lại; transformer là thứ bảo nó nối các dòng lại.
Cách đọc hình: hàng trên là profile fatjar-broken: hai jar đều có file META-INF/services/java.sql.Driver ở cùng một đường dẫn, công cụ đóng gói chỉ giữ một file và bỏ file kia, nên danh sách chỉ còn driver MySQL và URL jdbc:h2 báo No suitable driver dù lớp của H2 vẫn nằm trong jar. Hàng dưới là fatjar-fixed: transformer nối các dòng của hai file lại nên cả hai driver được đăng ký.
Đang tải Animation…
Mẹo chẩn đoán: khi báo No suitable driver mà URL đúng, hãy so sánh danh sách đầy đủ giữa hai môi trường. Lệnh unzip -p app.jar META-INF/services/java.sql.Driver cho thấy file trong jar; chạy DriverManager.drivers() in tên lớp ngay trên máy chủ cho thấy kết quả thực tế. Dù công cụ đóng gói nào, nguyên tắc là một: file dịch vụ phải được gộp, không được ghi đè.
Driver MySQL gửi gì lên máy chủ
Giả định phổ biến: "PreparedStatement được máy chủ biên dịch sẵn, tham số đi riêng". Để kiểm chứng, bài dùng chương trình WhatDriverSends. Nó bật general_log của MySQL (ghi lại mọi lệnh máy chủ nhận được) bằng tài khoản quản trị, mở một kết nối bằng tài khoản app, chạy một PreparedStatement có tham số, đóng kết nối, rồi đọc lại log của đúng kết nối đó để in ra. Chương trình dùng lại DbConfig và bảng product ở trên (chạy ApiTour một lần trước để tạo bảng).
src/main/java/dev/canhdominich/jdbc/intro/WhatDriverSends.java
package dev.canhdominich.jdbc.intro;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public final class WhatDriverSends {
private WhatDriverSends() {
}
public static void main(String[] args) throws SQLException {
DbConfig app = DbConfig.fromEnvironment();
DbConfig root = new DbConfig(app.url(), "root", System.getenv().getOrDefault("DB_ROOT_PASSWORD", "root_pw"));
for (String line : trace(app, root)) {
System.out.println(line);
}
}
static List<String> trace(DbConfig app, DbConfig root) throws SQLException {
List<String> lines = new ArrayList<>();
try (Connection admin = DriverManager.getConnection(root.url(), root.user(), root.password())) {
try (Statement statement = admin.createStatement()) {
statement.execute("SET GLOBAL log_output = 'TABLE'");
statement.execute("SET GLOBAL general_log = 'ON'");
}
long connectionId;
try (Connection connection = DriverManager.getConnection(app.url(), app.user(), app.password())) {
connectionId = connectionId(connection);
try (PreparedStatement ps = connection.prepareStatement("SELECT name FROM product WHERE sku = ?")) {
ps.setString(1, "KB-01");
try (ResultSet rs = ps.executeQuery()) {
rs.next();
}
}
}
try (Statement statement = admin.createStatement()) {
statement.execute("SET GLOBAL general_log = 'OFF'");
try (ResultSet rs = statement.executeQuery(
"SELECT command_type, CONVERT(argument USING utf8mb4) FROM mysql.general_log WHERE thread_id = " + connectionId + " ORDER BY event_time")) {
while (rs.next()) {
lines.add(rs.getString(1) + " " + rs.getString(2));
}
}
statement.execute("TRUNCATE mysql.general_log");
}
}
return lines;
}
private static long connectionId(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT CONNECTION_ID()")) {
rs.next();
return rs.getLong(1);
}
}
}
Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.intro.WhatDriverSends. Những gì máy chủ nhận được, theo thứ tự (rút gọn câu đầu):
Connect app@172.21.0.1 on shop using SSL/TLS
Query /* mysql-connector-j-26.7.0 ... */SELECT @@session.auto_increment_increment AS ..., @@sql_mode AS sql_mode, @@time_zone AS time_zone, ...
Query SET character_set_results = NULL
Query SET autocommit=1
Query SELECT CONNECTION_ID()
Query SELECT name FROM product WHERE sku = 'KB-01'
Quit
Cách đọc hình: từ trái sang phải là các bước một kết nối mới phải đi qua: bắt tay TCP và TLS, đăng nhập, một câu SELECT đọc cấu hình phiên, hai lệnh SET, rồi mới tới câu SQL của bạn. Ba bước giữa là những lệnh thấy được trong log của MySQL ở trên. Tổng chi phí này là lý do mở kết nối mới cho mỗi request rất đắt.
Hai điều quan trọng:
Một lần getConnection tốn nhiều hơn một lần bắt tay mạng. Ngoài TCP và TLS, driver còn gửi một truy vấn đọc cấu hình phiên (bộ ký tự, sql_mode, múi giờ, mức cô lập, max_allowed_packet, và nhiều biến khác) cùng hai lệnh SET. Đây là lý do mở kết nối mới cho mỗi request rất đắt, và vì sao bài sau dùng connection pool.
Mặc định, PreparedStatement không được máy chủ biên dịch sẵn. Máy chủ nhận một Query có giá trị đã nằm trong chuỗi (sku = 'KB-01'), không có lệnh Prepare hay Execute nào. Tên lớp ClientPreparedStatement là lời giải thích: driver tự thay dấu ? bằng giá trị đã được thoát ký tự ở phía máy khách. Điều này vẫn an toàn trước SQL injection nếu driver thoát đúng, nhưng cơ chế khác với hình dung "dữ liệu đi tách khỏi câu lệnh". Chạy lại với JDBC_URL='jdbc:mysql://localhost:3307/shop?useServerPrepStmts=true' mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.intro.WhatDriverSends và log máy chủ đổi hẳn: Prepare SELECT name FROM product WHERE sku = ?, rồi Execute, rồi Close stmt. Bài CRUD sẽ đo thêm sự khác biệt giữa hai chế độ.
Sáu bước làm việc, viết đúng
try (Connection connection = DriverManager.getConnection(url, user, password);
PreparedStatement ps = connection.prepareStatement("SELECT id, price FROM product WHERE price > ?")) {
ps.setBigDecimal(1, new BigDecimal("10.00"));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
BigDecimal price = rs.getBigDecimal("price");
}
}
}
| Bước | Việc | Điều hay sai |
|---|---|---|
| 1 | Driver đăng ký (tự động) | Chép lại Class.forName từ tài liệu cũ |
| 2 | Mở Connection | Mở mới mỗi request, không dùng pool |
| 3 | Tạo PreparedStatement | Nối chuỗi SQL với dữ liệu người dùng |
| 4 | Gán tham số rồi thực thi | Gán sai kiểu hoặc sai chỉ số (chỉ số bắt đầu từ 1) |
| 5 | Duyệt ResultSet | Đọc trước khi gọi next() |
| 6 | Đóng tài nguyên | Quên đóng, hoặc đóng sai thứ tự |
Phần closeCascade của ApiTour ở trên xác nhận quy tắc đóng: khi đóng Connection, mọi Statement và ResultSet tạo ra từ nó cũng bị đóng (statement.isClosed() và resultSet.isClosed() đều là true). Bạn vẫn nên dùng try-with-resources cho cả ba, vì lý do khác: trong ứng dụng có pool, close() trên connection chỉ trả nó về pool, và việc đóng tường minh statement mới giải phóng tài nguyên của lần dùng đó ngay.
Cột price kiểu DECIMAL(10,2) được driver ánh xạ sang java.math.BigDecimal (dòng price column type trong kết quả của ApiTour). Tiền tệ dùng BigDecimal, không dùng double.
Ba hành vi của API khiến người mới viết sai
getInt trên cột NULL trả về 0
Bảng mẫu của Schema có cột stock INT NULL, và dòng Mouse để NULL. Phần nullHandling của ApiTour đọc dòng đó theo ba cách:
getInt = 0, wasNull = true
getObject = null
getObject(Integer.class) = null
getInt không thể trả null vì kiểu int không chứa được, nên nó trả 0. Một ứng dụng bán hàng đọc rs.getInt("stock") sẽ coi sản phẩm "chưa biết tồn kho" thành "hết hàng" mà không có lỗi nào. Hai cách đúng: gọi rs.wasNull() ngay sau lần đọc, hoặc dùng rs.getObject("stock", Integer.class) để nhận null thật.
executeQuery tự từ chối câu lệnh không phải SELECT
Chạy executeQuery("SELEC 1") (gõ thiếu chữ L), phần cuối của ApiTour, không cho lỗi cú pháp của MySQL. Ta nhận SQLException thường, SQLState S1009, thông điệp Statement.executeQuery() cannot issue statements that do not produce result sets. Đây là lỗi do driver phát hiện ở phía máy khách trước khi gửi gì lên máy chủ, vì câu lệnh không bắt đầu như một lệnh trả tập kết quả. Khi một lỗi có SQLState lạ như S1009, hãy nghĩ tới lỗi từ driver chứ không phải từ cơ sở dữ liệu.
Lớp ngoại lệ, SQLState và mã lỗi là ba tầng thông tin
Phần exceptionAnatomy của ApiTour tạo hai lỗi:
| Thao tác | Lớp ngoại lệ | SQLState | Mã lỗi nhà cung cấp |
|---|---|---|---|
| Chèn trùng giá trị cột UNIQUE | SQLIntegrityConstraintViolationException | 23000 | 1062 |
| Truy vấn bảng không tồn tại | SQLSyntaxErrorException | 42S02 | 1146 |
SQLIntegrityConstraintViolationException kế thừa từ SQLNonTransientException: loại lỗi mà thử lại cùng lệnh sẽ vẫn lỗi. Nhóm kia, SQLTransientException, là lỗi có thể khỏi khi thử lại (ví dụ hết thời gian chờ pool). SQLState là mã chuẩn theo SQL, năm ký tự, có nghĩa trên mọi cơ sở dữ liệu; mã lỗi nhà cung cấp (1062, 1146) chỉ có nghĩa với MySQL. Code bắt lỗi nên dựa vào lớp ngoại lệ hoặc SQLState khi muốn dùng được trên nhiều cơ sở dữ liệu, và chỉ dựa vào mã nhà cung cấp khi cần phân biệt chi tiết (ví dụ trùng khóa 1062 để trả về thông báo "email đã tồn tại").
Cách đọc hình: SQLException là gốc; nhánh SQLNonTransientException gồm các lỗi thử lại cũng vẫn lỗi (trùng khóa, sai cú pháp, bảng không tồn tại), nhánh SQLTransientException gồm lỗi có thể khỏi khi thử lại, trong đó có các lỗi khóa 1213 và 1205 của MySQL (bài Transaction sẽ đo). Ô xám bên dưới là lỗi do chính driver phát hiện (S1009), không phải do máy chủ.
Thông tin mặc định của phiên
Lab đọc các giá trị mặc định ngay sau khi kết nối: autoCommit là true, mức cô lập là REPEATABLE_READ (giá trị 4), và DatabaseMetaData.supportsTransactions() cùng supportsBatchUpdates() đều là true. Hai giá trị đầu quan trọng cho bài Transaction: với autoCommit=true, mỗi lệnh là một transaction riêng và được ghi ngay.
Quyết định thiết kế và đánh đổi
| Quyết định | Lựa chọn | Đánh đổi |
|---|---|---|
| Code theo interface JDBC hay lớp driver | Luôn theo interface java.sql | Không dùng được tính năng riêng của MySQL trừ khi ép kiểu hoặc dùng unwrap, nhưng đổi được cơ sở dữ liệu |
DriverManager hay DataSource | Công cụ chạy một lần dùng DriverManager; ứng dụng chạy dài dùng DataSource có pool | Pool thêm một thành phần phải cấu hình |
| Đóng gói nhiều driver | Dùng transformer gộp file dịch vụ, hoặc để Spring Boot, Gradle xử lý | Phải kiểm tra bằng lệnh liệt kê driver sau khi đóng gói |
| Đọc cột có thể NULL | getObject(col, Type.class) hoặc wasNull() | Dài hơn getInt, nhưng không nhầm NULL thành 0 |
Bắt SQLException | Theo lớp con hoặc SQLState | Bắt Exception chung làm mất khả năng phân loại lỗi |
Playbook: gặp lỗi liên quan driver thì làm gì
| Triệu chứng | Kiểm tra đầu tiên | Nguyên nhân hay gặp | Cách xử lý |
|---|---|---|---|
No suitable driver, chạy được trong IDE nhưng không chạy được trong jar | unzip -p app.jar META-INF/services/java.sql.Driver | Fat jar ghi đè file dịch vụ | Thêm ServicesResourceTransformer hoặc dùng công cụ đóng gói biết gộp |
No suitable driver ở mọi môi trường | Đọc tiền tố URL, kiểm tra dependency | Gõ sai tiền tố hoặc thiếu jar | Sửa URL, thêm dependency |
ClassNotFoundException: com.mysql.jdbc.Driver | Tên lớp đang gọi | Tài liệu cũ dùng tên lớp của Connector/J 5.x | Bỏ Class.forName; tên lớp mới là com.mysql.cj.jdbc.Driver |
| Số tồn kho "về 0" dù dữ liệu NULL | Kiểu cột và cách đọc | getInt trên cột NULL | Dùng wasNull() hoặc getObject(col, Integer.class) |
Lỗi có SQLState S1009 | Câu lệnh gửi đi | Dùng executeQuery cho lệnh không trả tập kết quả | Dùng executeUpdate hoặc execute |
| Bắt lỗi trùng khóa bằng so sánh chuỗi thông điệp | Lớp ngoại lệ | Phụ thuộc ngôn ngữ và phiên bản | Bắt SQLIntegrityConstraintViolationException và kiểm tra mã 1062 |
Lỗi thường gặp khi chạy lab
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Bind for 0.0.0.0:3307 failed: port is already allocated | Cổng 3307 đã bị chiếm | MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop |
Table 'shop.product' doesn't exist khi chạy WhatDriverSends | Chưa tạo bảng | Chạy ApiTour một lần |
Access denied for user 'root' ở WhatDriverSends | Mật khẩu root khác mặc định | export DB_ROOT_PASSWORD=<mật khẩu root> |
Could not find a valid Docker environment khi mvn test | Docker chưa chạy | Bật Docker rồi chạy lại |
Bài thực hành
Làm trong thư mục 02-jdbc/01-gioi-thieu-jdbc. Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.
- Xem danh sách driver. Chạy
DriverDiscovery. Tiêu chí: thấy cảcom.mysql.cj.jdbc.Drivervàorg.h2.Drivermà code không gọiClass.forName. - Gây lỗi fat jar. Chạy
mvn -q -DskipTests -Pfatjar-broken packagerồi chạyDriverDiscoverytừlab-broken.jar. Tiêu chí:getDriver(jdbc:h2:mem:lab)báo 08001;unzip -p target/lab-broken.jar META-INF/services/java.sql.Driverchỉ có một dòng. - Sửa fat jar. Làm tương tự với
-Pfatjar-fixed. Tiêu chí: file dịch vụ có hai dòng, cả hai URL đều có driver. - Quan sát máy chủ. Chạy
ApiTourmột lần để tạo bảng, sau đó chạyWhatDriverSends. Tiêu chí: thấy câuSET autocommit=1vàWHERE sku = 'KB-01'với giá trị nằm trong chuỗi. - Bẫy NULL. Chạy
ApiTourvà đọc phần "null handling". Tiêu chí: giải thích được vì saogetIntcho 0 trong khigetObjectchonull. - Đọc lỗi. Tự thêm một thí nghiệm gây lỗi
Data too long for columnbằng cách chèn tên dài hơn 100 ký tự vào cộtname. Tiêu chí: ghi lại lớp ngoại lệ, SQLState và mã lỗi, rồi so với bảng trong bài. - Chạy bộ test (tùy chọn). Bộ test tự động của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone,
mvn testchoTests run: 8, Failures: 0, Errors: 0. Dọn dẹp bằngdocker compose down.
Hiểu lầm và giới hạn
- "Cần
Class.forNameđể nạp driver." Không cần từ JDBC 4.0, miễn jar có file dịch vụ nguyên vẹn. - "Driver có trong jar nghĩa là đã được đăng ký." Chỉ khi file
META-INF/services/java.sql.Drivercòn đúng. - "
PreparedStatementluôn được máy chủ biên dịch sẵn." Với Connector/J mặc định, câu lệnh được ghép phía máy khách; lab chỉ ra máy chủ không nhận lệnhPrepare. - "
getInttrả lỗi khi gặp NULL." Nó trả 0 một cách im lặng. - Số liệu của bài đến từ một cấu hình: MySQL 8.4.9, Connector/J 26.7.0, JDK 17. Chuỗi lệnh khởi tạo kết nối có thể khác ở phiên bản driver khác; phần này đáng đo lại khi bạn nâng cấp.
- Bài không đo driver của cơ sở dữ liệu khác ngoài H2 và MySQL.
Tự kiểm tra
- Vì sao
DriverManagerbiết có driver MySQL dù code không hề nhắc tới tên lớp của nó? - Fat jar chứa driver H2 nhưng
getConnection("jdbc:h2:mem:x")báoNo suitable driver. Hai lệnh nào giúp bạn tìm ra nguyên nhân? - Vì sao
getInttrên cột NULL nguy hiểm hơn mộtNullPointerException? - Máy chủ nhận được lệnh
Preparekhi nào, và với cấu hình mặc định thì nó nhận gì? - Khi bắt lỗi trùng email, bạn nên dựa vào thông điệp lỗi, lớp ngoại lệ hay mã lỗi, và vì sao?
Gợi ý đáp án. (1) File META-INF/services/java.sql.Driver trong jar driver, được ServiceLoader đọc khi DriverManager khởi tạo. (2) unzip -p file dịch vụ trong jar, và in DriverManager.drivers() trên chính máy chủ; hoặc so sánh danh sách đó giữa IDE và jar. (3) Không có lỗi nào xuất hiện, dữ liệu sai lặng lẽ đi vào nghiệp vụ. (4) Khi bật useServerPrepStmts=true; mặc định máy chủ nhận một Query có giá trị đã ghép sẵn. (5) Lớp SQLIntegrityConstraintViolationException kèm mã 1062 khi cần chắc chắn là trùng khóa; thông điệp thay đổi theo phiên bản và ngôn ngữ.
Điều cần nhớ
- JDBC là hợp đồng nằm trong JDK; driver của nhà cung cấp là phần thực hiện, nạp qua
ServiceLoader. No suitable driverchỉ có hai nguyên nhân: sai tiền tố URL hoặc driver chưa được đăng ký, trong đó fat jar ghi đè file dịch vụ là dạng khó thấy.- Một lần
getConnectiongửi nhiều lệnh cấu hình lên máy chủ, vì vậy không mở kết nối mới cho mỗi request. - Mặc định
PreparedStatementcủa Connector/J ghép tham số ở phía máy khách. getInttrên NULL trả 0; phân loại lỗi bằng lớp ngoại lệ và SQLState.
Đọc thêm
- Java SE 17: module java.sql, danh sách interface JDBC trong JDK.
- MySQL Connector/J Developer Guide: JDBC API Implementation Notes, ghi chú cài đặt của driver.
- Apache Maven Shade Plugin: ServicesResourceTransformer
Bài tiếp theo, Kết nối MySQL, đi sâu vào URL, lỗi kết nối, TLS, múi giờ và connection pool.