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à DriverManager chọn driver theo tiêu chí nào.
  • Chẩn đoán lỗi No suitable driver gồm cả trường hợp fat jar, và sửa đúng.
  • Dự đoán hành vi PreparedStatement mặc định của MySQL: câu lệnh được gửi đi thế nào.
  • Dùng đúng getInt vớ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ầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Docker + Docker Composebất kỳ bản gần đâyMySQL cho demo và Testcontainers cho test
MySQL8.4 (image mysql:8.4)
MySQL Connector/J26.7.0khai báo trong pom.xml
H22.5.252driver thứ hai, chỉ để so sánh việc chọn driver

Cấu trúc

text
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
<?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.

yaml
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:

bash
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 ApiTour ngay bên dưới in ra tên lớp thật: Connection là com.mysql.cj.jdbc.ConnectionImpl, PreparedStatement là com.mysql.cj.jdbc.ClientPreparedStatement, ResultSet là com.mysql.cj.jdbc.result.ResultSetImpl. Cái tên ClientPreparedStatement sẽ 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

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

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

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ả:

text
-- 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ạiTênCách hoạt độngHiện nay
Type 1JDBC-ODBC bridgeChuyển lời gọi sang ODBC của hệ điều hànhĐã bị gỡ khỏi JDK từ Java 8
Type 2Native APIGọi thư viện native do nhà cung cấp phát hànhHiếm, gắn với hệ điều hành
Type 3Network protocolNói chuyện với một máy chủ trung gianGần như không còn
Type 4Thin driverThuần Java, tự nói giao thức mạng của cơ sở dữ liệuChuẩ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

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ả:

text
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:

URLKết quả
jdbc:mysql://localhost:3307/shopcom.mysql.cj.jdbc.Driver
jdbc:h2:mem:laborg.h2.Driver
jdbc:postgresql://localhost:5432/shopSQLException, SQLState 08001, No suitable driver (không có driver PostgreSQL trên classpath)
mysql://localhost:3307/shopSQLException, 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:

xml
<transformers>
    <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>

Đóng gói và chạy:

bash
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:

JarNội dung META-INF/services/java.sql.DriverServiceLoader thấygetDriver(jdbc:h2:mem:lab)
lab-broken.jarmột dòng: com.mysql.cj.jdbc.Driverchỉ MySQLSQLException 08001, No suitable driver
lab-fixed.jarhai dòng: MySQL và H2cả haiorg.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

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):

text
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

java
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ướcViệcĐiều hay sai
1Driver đăng ký (tự động)Chép lại Class.forName từ tài liệu cũ
2Mở ConnectionMở mới mỗi request, không dùng pool
3Tạo PreparedStatementNối chuỗi SQL với dữ liệu người dùng
4Gán tham số rồi thực thiGán sai kiểu hoặc sai chỉ số (chỉ số bắt đầu từ 1)
5Duyệt ResultSetĐọc trước khi gọi next()
6Đóng tài nguyênQuê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:

text
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ácLớp ngoại lệSQLStateMã lỗi nhà cung cấp
Chèn trùng giá trị cột UNIQUESQLIntegrityConstraintViolationException230001062
Truy vấn bảng không tồn tạiSQLSyntaxErrorException42S021146

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 địnhLựa chọnĐánh đổi
Code theo interface JDBC hay lớp driverLuôn theo interface java.sqlKhô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 DataSourceCông cụ chạy một lần dùng DriverManager; ứng dụng chạy dài dùng DataSource có poolPool thêm một thành phần phải cấu hình
Đóng gói nhiều driverDù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ể NULLgetObject(col, Type.class) hoặc wasNull()Dài hơn getInt, nhưng không nhầm NULL thành 0
Bắt SQLExceptionTheo lớp con hoặc SQLStateBắ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ứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
No suitable driver, chạy được trong IDE nhưng không chạy được trong jarunzip -p app.jar META-INF/services/java.sql.DriverFat 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 dependencyGõ sai tiền tố hoặc thiếu jarSửa URL, thêm dependency
ClassNotFoundException: com.mysql.jdbc.DriverTên lớp đang gọiTài liệu cũ dùng tên lớp của Connector/J 5.xBỏ Class.forName; tên lớp mới là com.mysql.cj.jdbc.Driver
Số tồn kho "về 0" dù dữ liệu NULLKiểu cột và cách đọcgetInt trên cột NULLDùng wasNull() hoặc getObject(col, Integer.class)
Lỗi có SQLState S1009Câu lệnh gửi điDù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ệpLớp ngoại lệPhụ thuộc ngôn ngữ và phiên bảnBắt SQLIntegrityConstraintViolationException và kiểm tra mã 1062

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Bind for 0.0.0.0:3307 failed: port is already allocatedCổng 3307 đã bị chiếmMYSQL_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 WhatDriverSendsChưa tạo bảngChạy ApiTour một lần
Access denied for user 'root' ở WhatDriverSendsMật khẩu root khác mặc địnhexport DB_ROOT_PASSWORD=<mật khẩu root>
Could not find a valid Docker environment khi mvn testDocker chưa chạyBậ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.

  1. Xem danh sách driver. Chạy DriverDiscovery. Tiêu chí: thấy cả com.mysql.cj.jdbc.Driver và org.h2.Driver mà code không gọi Class.forName.
  2. Gây lỗi fat jar. Chạy mvn -q -DskipTests -Pfatjar-broken package rồi chạy DriverDiscovery từ 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.Driver chỉ có một dòng.
  3. 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.
  4. Quan sát máy chủ. Chạy ApiTour một lần để tạo bảng, sau đó chạy WhatDriverSends. Tiêu chí: thấy câu SET autocommit=1 và WHERE sku = 'KB-01' với giá trị nằm trong chuỗi.
  5. Bẫy NULL. Chạy ApiTour và đọc phần "null handling". Tiêu chí: giải thích được vì sao getInt cho 0 trong khi getObject cho null.
  6. Đọc lỗi. Tự thêm một thí nghiệm gây lỗi Data too long for column bằng cách chèn tên dài hơn 100 ký tự vào cột name. 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.
  7. 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 test cho Tests run: 8, Failures: 0, Errors: 0. Dọn dẹp bằng docker 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.Driver còn đúng.
  • "PreparedStatement luô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ệnh Prepare.
  • "getInt trả 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

  1. Vì sao DriverManager biết có driver MySQL dù code không hề nhắc tới tên lớp của nó?
  2. Fat jar chứa driver H2 nhưng getConnection("jdbc:h2:mem:x") báo No suitable driver. Hai lệnh nào giúp bạn tìm ra nguyên nhân?
  3. Vì sao getInt trên cột NULL nguy hiểm hơn một NullPointerException?
  4. Máy chủ nhận được lệnh Prepare khi nào, và với cấu hình mặc định thì nó nhận gì?
  5. 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 driver chỉ 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 getConnection gử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 PreparedStatement của Connector/J ghép tham số ở phía máy khách.
  • getInt trên NULL trả 0; phân loại lỗi bằng lớp ngoại lệ và SQLState.

Đọc thêm

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.