← 返回首页
Java学习#Java

14.JDBC 数据库操作与JDBC工具类的反射封装应用

2026-10-11·0 次浏览
14.JDBC 数据库操作与JDBC工具类的反射封装应用

JDBC 数据库操作与反射封装:把查询结果变成 Java 对象

在 SQL 客户端里执行查询,我们得到的是一张结果表。在 Java 程序里,通常希望得到 User、Student 这样的对象,方便继续处理。

这篇分成两大部分:第一部分讲 JDBC 的基本操作,第二部分讲 JDBC 工具类的实现。重点是反射的实际应用:工具类怎样根据 User.class 创建对象,再把数据库里的 id、name 填进对象。 不急着把代码一次写完,先看每一步为什么需要,再把它们接起来。

示例使用 Java 17 及以上、MySQL 8.0 / 8.4。后面的 Java 调用片段放在方法中执行,需要的 import 放在类声明前;涉及 JDBC 的方法需要捕获或声明 SQLException。

1 JDBC 基础

这一部分先学会连接数据库、执行 SQL、读取结果,为后面的工具类封装打好基础。

1.1 JDBC 是什么,为什么需要它

JDBC(Java Database Connectivity)是 Java 访问关系型数据库的一套标准 API。 我们通过它建立连接、执行 SQL,再读取查询结果。

比如,数据库保存了张三的姓名,Java 想把它显示到页面上,就不能只在 SQL 工具里查一次,而要由程序连接数据库并获取数据。

JDBC 规定了共同的操作接口,数据库驱动负责具体通信:

text 复制代码
Java 程序
   ↓ 调用 Connection、PreparedStatement 等接口
JDBC 驱动
   ↓ 与服务器通信
MySQL / Oracle / PostgreSQL 等数据库

MySQL 使用 MySQL Connector/J 驱动,其他数据库使用对应驱动。换数据库时,连接和执行代码的基本形式可以相近,但 SQL 语法、类型、分页方式等仍可能不同,JDBC 不会自动消除这些差异。

一次数据库访问通常有六步:

  1. 将驱动加入项目依赖。
  2. 获取 Connection。
  3. 创建 Statement 或 PreparedStatement。
  4. 执行 SQL。
  5. 有查询结果时,读取 ResultSet。
  6. 关闭结果集、执行对象和连接。

1.1.1 加入 MySQL 驱动

Maven 项目可以添加下面的依赖。这里使用一个明确的示例版本,实际项目应按数据库和 JDK 版本选择受支持的 Connector/J 版本:

xml 复制代码
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.7.0</version>
</dependency>

只有 JDBC 接口还不够,运行时也要能找到驱动 JAR。否则,连接 MySQL 时可能出现 No suitable driver。

1.1.2 准备少量练习数据

先在 mytest1 中准备 test1 表,用它练习 JDBC 的基本操作。在独立练习环境中准备这张表,不要覆盖已有笔记例子的表。 如果表名已被占用,可以换一个练习数据库,并同步修改连接 URL。

sql 复制代码
CREATE DATABASE IF NOT EXISTS mytest1 DEFAULT CHARACTER SET utf8mb4;
USE mytest1;

CREATE TABLE test1 (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20)
);
INSERT INTO test1 (name) VALUES ('张三'), ('李四'), ('王五');

1.2 JDBC 的两部分与常见对象

从使用者的角度看,JDBC 有两部分:一部分是 Java 提供的接口,另一部分是驱动提供的实现。我们写 Connection,运行时拿到的连接对象由具体驱动提供。

  • java.sql:连接、执行 SQL、读取结果等核心 API。
  • javax.sql:DataSource 等扩展 API,常用于数据源、连接池场景。

这里仍然叫 javax.sql,不要因为学过 Jakarta Servlet,就把它改成 jakarta.sql。

类或接口 用来做什么
DriverManager 根据连接信息获取连接
Connection 表示数据库连接,创建执行对象、管理事务
Statement 执行普通 SQL
PreparedStatement 执行带参数的 SQL,单独绑定参数值
ResultSet 逐行读取查询结果
ResultSetMetaData 读取结果的列数、列标签、列类型等
DataSource 提供连接;具体实现可以支持连接池

DataSource 本身不等于连接池。连接池实现通常通过它提供连接,减少反复建立物理连接的开销。

1.3 核心对象:从连接到读取一行数据

1.3.1 DriverManager:获取连接

先看一个连接调用:

java 复制代码
Connection conn = DriverManager.getConnection(
    "jdbc:mysql://localhost:3307/mytest1",
    "root",
    "root"
);

这里保留练习用的地址和账号:localhost 是当前机器,3307 是示例端口,mytest1 是数据库名。MySQL 常见默认端口是 3306,要以自己的配置为准;root/root 也只是示例,密码不是 MySQL 自动规定的。

getConnection() 会寻找能处理这个 URL 的驱动,并尝试认证和连接。成功返回 Connection,失败通常抛出 SQLException。真实项目应使用合适权限的业务账号,并从配置中获取密码。

上面为了认识调用单独写了连接。真正执行操作时,要把连接放进 try-with-resources,下面就这样做。

1.3.2 Connection:创建执行对象

Connection 是程序持有的连接对象,不是数据库本身。它可以创建执行对象,也可以设置自动提交、提交和回滚事务。

java 复制代码
PreparedStatement ps = conn.prepareStatement(
    "SELECT id, name FROM test1 WHERE id = ?"
);

conn 是前面获取的连接,prepareStatement() 准备 SQL 执行对象。这一步还没有把查询结果读回来,接下来要绑定参数并执行。

1.3.3 Statement 与 PreparedStatement

没有参数时,可以用 Statement:

java 复制代码
// 这里使用已有 conn,结束时关闭 stmt 和查询结果。
try (Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(
         "SELECT id, name FROM test1 ORDER BY id")) {
    while (rs.next()) {
        System.out.println(rs.getInt("id") + " " + rs.getString("name"));
    }
}

需要传入编号时,使用 PreparedStatement 更清楚。下面给出第一个完整程序:

java 复制代码
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class JdbcDemo {
    public static void main(String[] args) throws SQLException {
        String url = "jdbc:mysql://localhost:3307/mytest1";
        String sql = "SELECT id, name FROM test1 WHERE id = ?";

        try (Connection conn = DriverManager.getConnection(url, "root", "root");
             PreparedStatement ps = conn.prepareStatement(sql)) {
            // JDBC 参数编号从 1 开始:给第一个 ? 传入编号 1。
            ps.setInt(1, 1);

            try (ResultSet rs = ps.executeQuery()) {
                // 游标开始时在第一行之前,先 next() 才能读当前行。
                while (rs.next()) {
                    System.out.println(rs.getInt("id") + " " + rs.getString("name"));
                }
            }
        }
    }
}

使用第 1.1 节的初始数据,输出是:

text 复制代码
1 张三

这里 SQL 结构和编号值分开提供,后续换一个编号只需修改参数。PreparedStatement 可以减少拼接错误、防范通过参数值进行的 SQL 注入,也便于重复使用 SQL 模板。

占位符只表示数据值,不能直接表示表名、列名、排序方向或关键字。 SELECT * FROM ? 不能用它动态选择表;WHERE ? = ? 的第一个参数也只是值,不会变成列名。动态排序等需求,需要程序从允许的名称中选择,再组成 SQL。

不要把它的优势记成“一定只编译一次、一定更快”。是否采用服务器端预处理,还与驱动实现、配置和执行方式有关。

1.3.4 ResultSet:一行一行读取

ResultSet 提供查询结果的读取接口,可以把它理解为带游标的结果表,不必假设所有行都已经一次性装进内存。

next() 将游标向前移动:有下一行返回 true,没有返回 false。可以按列标签读取,也可以按从 1 开始的列编号读取。

java 复制代码
// 假设 rs 来自 SELECT id, name FROM test1。
while (rs.next()) {
    int id = rs.getInt(1);             // 当前行的第一列。
    String name = rs.getString("name");
    System.out.println(id + " " + name);
}

SQL 的 NULL 要单独留意:getString() 可以返回 null,而 getInt() 遇到 SQL NULL 会返回 0。需要区分“确实是 0”和“没有值”时,应紧跟着调用 wasNull(),或者读取可空包装类型。后面的反射工具类使用 getObject(),保留空值,再按字段类型处理。

1.3.5 ResultSetMetaData:先看看结果有哪些列

当查询固定是 id、name 时,我们可以直接写读取代码。但工具类还要查询其他表,就需要先问结果集:“你有几列,每列叫什么?”

java 复制代码
ResultSetMetaData meta = rs.getMetaData();
int count = meta.getColumnCount();

// JDBC 的列编号从 1 开始,所以最后一列的编号是 count。
for (int i = 1; i <= count; i++) {
    System.out.println(meta.getColumnLabel(i));
}

对于 SELECT id, name FROM test1,列数是 2,依次打印 id 和 name。还可以用 getColumnName(i) 获取列名,getColumnType(i) 获取 SQL 类型,getColumnTypeName(i) 获取类型名称。

列标签不一定等于数据库列名。 例如 SELECT user_name AS username FROM users 中,getColumnLabel(1) 是 username;getColumnName(1) 用于取得底层列名,具体行为应结合驱动。后面按标签映射对象,因此可以通过 AS 指定要匹配的 Java 字段。

1.4 执行 SQL 的三个方法

不同 SQL 要用合适的执行方法。查询要读取结果,增删改通常只关心影响行数。

方法 返回值 典型用途
executeQuery() ResultSet 执行返回一个结果集的查询
executeUpdate() int 增删改,以及不返回结果集的部分 DDL
execute() boolean 第一个结果类型不确定,或可能产生多个结果

1.4.1 executeQuery:取得结果集

对于 SELECT id, name FROM test1,绑定参数后调用:

java 复制代码
try (ResultSet rs = ps.executeQuery()) {
    while (rs.next()) {
        System.out.println(rs.getString("name"));
    }
}

这里的 ps 应由查询 SQL 创建;不要用 executeQuery() 执行不返回结果集的普通 UPDATE。

1.4.2 executeUpdate:取得影响行数

INSERT INTO test1(name) VALUES (?)、UPDATE test1 SET name = ? WHERE id = ?、DELETE FROM test1 WHERE id = ?,都可以用它。

java 复制代码
// 假设 ps 已准备好增删改 SQL,并完成参数绑定。
int rows = ps.executeUpdate();
System.out.println("影响行数:" + rows);

按主键删除存在的一行,通常返回 1;没有对应记录通常返回 0。更新时,驱动配置还可能影响“匹配行数”与“实际变化行数”的报告方式,因此不能只看返回值就判断所有业务都成功。执行 DDL 时返回 0,也不表示执行失败。

1.4.3 execute:判断第一个结果是什么

java 复制代码
boolean hasResultSet = ps.execute();

if (hasResultSet) {
    // true 表示第一个结果是结果集,不是“执行成功”的标志。
    try (ResultSet rs = ps.getResultSet()) {
        while (rs.next()) {
            System.out.println(rs.getObject(1));
        }
    }
} else {
    System.out.println("更新计数:" + ps.getUpdateCount());
}

false 表示第一个结果不是结果集,可能是更新计数,也可能没有结果;getUpdateCount() 为 −1 表示此时没有更新计数。SQL 执行失败通常抛异常,不能把返回的 false 当成失败。

存储过程等可能产生多个结果,还需要结合 getMoreResults()、getUpdateCount() 继续处理。上面只演示第一个结果;普通单条查询或修改,优先使用前两种方法。

1.5 驱动加载与 Class 对象

1.5.1 为什么旧代码会写 Class.forName

常见的传统加载方式是:

java 复制代码
Class.forName("com.mysql.cj.jdbc.Driver");

这个调用根据全限定名加载并初始化驱动类,驱动可以在初始化过程中向 DriverManager 注册自己。

从 JDBC 4 开始,驱动可以通过服务提供者机制被发现。现代 Connector/J 在 JAR 和运行时类路径正确时,通常不需要手动调用上面这行。写了 Class.forName,也不能补救“根本没加入驱动 JAR”的问题。

1.5.2 User.class 为什么能告诉工具类创建 User

程序运行时,JVM 用 Class 对象表示某个类的类型信息。后面的工具类要创建什么对象,就是靠这个信息决定。

java 复制代码
Class<User> clazz = User.class;

这行得到的是“User 类的类型信息”,没有创建一个 User 实例。真正创建实例要调用构造方法,后面会演示。

如果类名来自字符串,也可以这样获取:

java 复制代码
// 假设 User 在默认包中;有包名时必须写全限定名,例如 demo.User。
Class<?> clazz = Class.forName("User");

User.class 直接引用已知类型,单纯取得类字面量不会触发该类初始化;Class.forName(String) 默认会加载并初始化指定类。

创建多个 User 实例,不会每次产生一个新的 Class 对象。通常,同一个类加载器加载的同一个类对应同一个 Class 对象;不同类加载器可以加载同名但身份不同的类。

2 JDBC 工具类

这一部分在前面 JDBC 操作的基础上提取公共代码,重点看反射怎样把查询结果封装成不同的 Java 对象。

2.1 为什么要做一个 JDBC 工具类

前面写一次查询,就需要连接、绑定参数、遍历结果和关闭资源。查询另一张表时,这些步骤又要重复。

继续使用第一部分准备的 mytest1.test1。再在同一个练习库中准备学生表,用它观察同一个工具类怎样返回另一种对象:

sql 复制代码
CREATE TABLE student (
    id INT PRIMARY KEY,
    name VARCHAR(20)
);
INSERT INTO student (id, name) VALUES (1, '小明'), (2, '小红');

因此,我们希望保留这样的调用形式:

java 复制代码
List<User> users = jdbc.query(
    "SELECT id, name FROM test1 WHERE id > ? ORDER BY id",
    User.class,
    0
);

换成学生查询,只换 SQL 和目标类型:

java 复制代码
List<Student> students = jdbc.query(
    "SELECT id, name FROM student WHERE id > ? ORDER BY id",
    Student.class,
    0
);

这里的 jdbc 会在下一节创建,User、Student 会在第 2.4 节定义。先看懂我们想得到什么,再实现这些组成部分。

三种技术各做一件事:

技术 在工具类里负责什么
JDBC 访问数据库,取得行和列
泛型 <T> 与 Class<T> 表达“传入 User 类型信息,就返回 User 列表”的类型关系
反射 运行时调用构造方法,找到字段并写入值

泛型不会替我们创建对象,反射也不会替我们查询数据库。 把它们连起来,才能完成这次封装。

text 复制代码
SQL + User.class + 参数
          ↓
连接数据库,执行查询
          ↓
读取列标签和当前行的值
          ↓
创建 User,按字段名填入值
          ↓
返回 List<User>

2.2 保存连接信息

先写 JDBCUtils 的外壳。第 2.3~2.7 节标明“加入 JDBCUtils”的方法,都放在这个类中,不要重复创建多个同名类。 为了便于阅读,各方法分开展示。

java 复制代码
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class JDBCUtils {
    private final String url;
    private final String user;
    private final String password;

    public JDBCUtils(String url, String user, String password) {
        this.url = url;
        this.user = user;
        this.password = password;
    }

    public Connection getConnection() {
        try {
            return DriverManager.getConnection(url, user, password);
        } catch (SQLException e) {
            // 保留原始异常,调用者才能继续查看驱动返回的原因。
            throw new RuntimeException("数据库连接失败", e);
        }
    }
}

构造方法保存连接信息。this.url 是对象的成员变量,右边的 url 是传入的参数;private 限制外部直接访问,final 表示这些字段赋值后不能再重新赋值。这里保存的是三个字符串,不是长时间持有一条连接。

调用时创建工具对象:

java 复制代码
JDBCUtils jdbc = new JDBCUtils(
    "jdbc:mysql://localhost:3307/mytest1", "root", "root"
);

getConnection() 每次调用都会尝试获取连接。连接失败时不能继续执行 SQL,因此抛出异常,而不是打印一句话后返回 null。包装成运行时异常是这个工具类的选择,也可以按项目需要保留受检异常接口。

2.3 通用增删改

增删改的 SQL 不同,但连接、绑定和执行流程相同,所以可以提取一个方法。

把下面的方法加入 JDBCUtils;其中 setParams() 在第 2.6 节定义:

java 复制代码
public int execute(String sql, Object... params) {
    try (Connection conn = getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {
        setParams(ps, params);
        return ps.executeUpdate();
    } catch (SQLException e) {
        throw new RuntimeException("SQL 执行失败:" + sql, e);
    }
}

原来可能分别写 addUser("张三")、updateUser(1, "李四")、deleteUser(1)。现在把不同 SQL 传进来,公共步骤交给工具类。

为了不改变第 1.1 节的三条数据,另插入一条编号为 10 的练习记录:

java 复制代码
int added = jdbc.execute(
    "INSERT INTO test1(id, name) VALUES (?, ?)", 10, "张三"
);
System.out.println(added); // 1

再修改并删除这条记录:

java 复制代码
int updated = jdbc.execute(
    "UPDATE test1 SET name = ? WHERE id = ?", "李四", 10
);
int deleted = jdbc.execute("DELETE FROM test1 WHERE id = ?", 10);
System.out.println(updated + " " + deleted); // 1 1

2.3.1 Object... params 怎样接收参数

Object... 是可变参数,允许传零个、一个或多个值。进入方法后,params 就是一个 Object[]。

比如修改语句的两个参数对应:

text 复制代码
params[0] = "李四"  → 第一个 ?,姓名
params[1] = 10      → 第二个 ?,编号

基本类型值会自动装箱,例如 10 变成 Integer。顺序必须与 SQL 占位符顺序一致,不能把“编号、姓名”传给需要“姓名、编号”的 SQL。

2.3.2 工具类的 execute 与 JDBC 的 execute 不同

工具类这里叫 execute(),但内部调用的是 ps.executeUpdate(),所以它返回 int,用于增删改。它不是第 1.4 节返回 boolean 的 JDBC execute()。

执行查询应调用后面的 query()。同样,影响行数只是数据库报告的结果,是否满足业务要求,仍要由调用者判断。

2.4 反射怎样把一行数据变成对象

这一节先把每一步拆开看,再给出完整的 query()。我们要解决的问题是:工具类不能写死 new User(),因为下次可能要创建 Student;也不能写死每个字段,因为查询列可能变化。

2.4.1 先准备要接收数据的类

User 只保留当前需要的两个字段,并提供无参构造方法:

java 复制代码
public class User {
    private Integer id;
    private String name;

    public User() {
    }

    @Override
    public String toString() {
        return "User(" + id + ", " + name + ")";
    }
}

Student 单独定义,结构相似,但它是另一个 Java 类型:

java 复制代码
public class Student {
    private Integer id;
    private String name;

    public Student() {
    }

    @Override
    public String toString() {
        return "Student(" + id + ", " + name + ")";
    }
}

使用 Integer 是为了能保留数据库的 NULL。非空的编号也可以用 int,但不能把空值直接赋给基本类型。

这里的无参构造方法是公开的,反射调用方便。若只写了有参构造方法,Java 不会再自动补无参构造方法,后面的创建步骤就会失败。

2.4.2 泛型声明:告诉方法想返回什么类型

查询方法使用 public <T> List<T> query(String sql, Class<T> clazz, Object... params) 这样的声明。

  • <T> 声明类型变量。
  • List<T> 表示返回该类型的对象列表。
  • Class<T> clazz 接收类型信息,例如 User.class。
  • Object... params 接收 SQL 参数。

传入 User.class 时,T 对应 User;传入 Student.class 时,T 对应 Student。如果以后有符合映射规则的 Product,也可以传 Product.class。

但 Java 的泛型不能直接写 new T()。所以还要传入 Class 对象,通过反射调用具体类的构造方法。

2.4.3 第一步:准备结果列表,执行查询

查询可能返回三行,所以先准备一个列表:

java 复制代码
List<T> list = new ArrayList<>();

再连接、绑定参数,执行查询:

java 复制代码
// 这是 query 方法中的执行步骤;方法完整版在第 2.4.9 节。
Connection conn = getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
setParams(ps, params);
ResultSet rs = ps.executeQuery();

这些单行代码是为了看清过程,完整方法会用 try-with-resources 管理它们。查询得到三行,就应该创建三个独立对象,再放进列表。

2.4.4 第二步:读取列信息

java 复制代码
ResultSetMetaData meta = rs.getMetaData();
int columnCount = meta.getColumnCount();

对于 SELECT id, name FROM test1,columnCount 为 2。后面可以用 getColumnLabel(1) 得到 id,用 getColumnLabel(2) 得到 name。

这一步得到的是“列叫什么”,具体某一行的值还要通过 rs.getObject(i) 读取。

2.4.5 第三步:每读到一行,反射创建一个对象

假设已经通过 rs.next() 移到了第一行,传入的 clazz 是 User.class:

java 复制代码
// 取得这个类声明的无参构造方法。
var constructor = clazz.getDeclaredConstructor();

// 调用构造方法,创建一个新的对象。
T obj = constructor.newInstance();

合并起来就是:

java 复制代码
T obj = clazz.getDeclaredConstructor().newInstance();

当 clazz 是 User.class,相当于创建 new User();当它是 Student.class,则创建 Student。工具类没有换,决定创建哪一种对象的是调用者传入的 Class。

getDeclaredConstructor() 只负责找到构造方法,不保证它可以直接访问。这个示例要求目标类可以访问,并有公开无参构造方法;目标是接口、抽象类、缺少无参构造方法等情况,都可能无法创建。

创建对象必须放在逐行循环内。如果在循环外只创建一次,再反复改字段并加入列表,列表里会多次引用同一个对象,最后看起来每项都成了最后一行。

2.4.6 第四步:用列标签找到字段

对于第一列 id,先根据名字查找 Java 字段:

java 复制代码
String columnName = meta.getColumnLabel(1); // id
String fieldName = toCamelCase(columnName); // id
Field field = findField(clazz, fieldName);

Field 表示字段本身的信息,包括名字、类型、修饰符等。它不是字段值,也没有绑定某个具体 User 对象。

列名有下划线时,转换成驼峰名称

数据库列可能叫 user_name,Java 字段可能叫 userName。约定每次遇到下划线,就把后一个字符转成大写。

把下面的方法加入 JDBCUtils:

java 复制代码
private String toCamelCase(String name) {
    StringBuilder sb = new StringBuilder();
    boolean upperNext = false;

    for (char c : name.toCharArray()) {
        if (c == '_') {
            // 下划线本身不保留,让下一个字母变成大写。
            upperNext = true;
        } else {
            sb.append(upperNext ? Character.toUpperCase(c) : c);
            upperNext = false;
        }
    }
    return sb.toString();
}
列标签 转换后的字段名
id id
user_name userName
create_time createTime
phone_number phoneNumber

这里只处理这种简单命名约定,不会自动猜出完全不同的名字,也不会自动把全部大写标签改成小写。列标签和字段名不一致时,可以使用 SQL 别名。

字段在父类中,也要继续找

getDeclaredField() 只查当前类自己声明的字段,所以需要逐层查看父类。把下面的方法加入 JDBCUtils:

java 复制代码
private Field findField(Class<?> clazz, String name) {
    Class<?> current = clazz;

    while (current != null && current != Object.class) {
        try {
            return current.getDeclaredField(name);
        } catch (NoSuchFieldException e) {
            // 当前类没有,继续检查父类声明的字段。
            current = current.getSuperclass();
        }
    }
    return null;
}

这与只查公有字段的 getField() 不同。逐层查声明字段,才能找到父类中的 private 字段。

2.4.7 第五步:允许访问私有字段,读取值并赋值

拿到 id 字段后,还不能把 Field 当成值直接输出。要指定“往哪个对象写、写什么”:

java 复制代码
field.setAccessible(true);
Object value = rs.getObject(1); // 当前行第一列,例如 Integer 类型的 1。

// 第一个参数是刚创建的对象,第二个参数是要写入字段的值。
field.set(obj, convertValue(value, field.getType()));

可以把它对应到这三个具体问题:

表达式 回答的问题
field.getType() id 字段要什么类型?这里是 Integer.class
rs.getObject(1) 这一行的 id 是什么值?这里是 1
field.set(obj, value) 把这个值写进哪个对象?这里是刚创建的 User

用姓名字段再做一次,就完成第一行:

java 复制代码
// 假设 User 在当前示例中可以访问,且有前面定义的无参构造方法。
User user = User.class.getDeclaredConstructor().newInstance();
Field nameField = User.class.getDeclaredField("name");
nameField.setAccessible(true);
nameField.set(user, "张三");
System.out.println(user);

输出是:

text 复制代码
User(null, 张三)

这个短例子没有给 id 赋值,所以它仍然为 null。它只演示反射赋值,可以不连接数据库运行。将字符串 "张三" 换成 rs.getObject(2),就接上了查询结果。

setAccessible(true) 起了什么作用

name 是 private,类外不能直接写 user.name = "张三"。这里请求反射访问不公开字段,从而允许工具类完成映射。

这不会把字段在源码里的 private 改成 public,也不能突破所有访问限制。Java 模块未开放的包等情况下,调用仍可能失败。此处针对我们自己定义、在普通类路径下运行的实体类。

Field.set() 直接写字段,不会自动调用 setter。因此 setter 里的校验逻辑也不会自动执行。这正是简单反射工具类需要明确的边界。

2.4.8 第六步:把对象放进列表,再处理下一行

java 复制代码
list.add(obj);

第一行创建 User(1, 张三),第二行重新创建 User(2, 李四),第三行创建 User(3, 王五)。列循环处理一个对象的多个字段,行循环处理多个对象,不要把这两个循环混在一起。

text 复制代码
第一行:创建对象 → 填 id → 填 name → 加入列表
第二行:创建对象 → 填 id → 填 name → 加入列表
第三行:创建对象 → 填 id → 填 name → 加入列表

2.4.9 把这几步接成完整 query 方法

把下面的方法加入 JDBCUtils。用到的名称转换、字段查找已经给出,类型转换和参数绑定分别在第 2.5、2.6 节给出。

java 复制代码
public <T> List<T> query(String sql, Class<T> clazz, Object... params) {
    List<T> list = new ArrayList<>();

    try (Connection conn = getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {
        setParams(ps, params);

        try (ResultSet rs = ps.executeQuery()) {
            ResultSetMetaData meta = rs.getMetaData();
            int columnCount = meta.getColumnCount();

            while (rs.next()) {
                // 每一行都创建一个新对象,不能在循环外反复复用。
                T obj = clazz.getDeclaredConstructor().newInstance();

                for (int i = 1; i <= columnCount; i++) {
                    String fieldName = toCamelCase(meta.getColumnLabel(i));
                    Field field = findField(clazz, fieldName);
                    if (field == null) {
                        // 此版本允许结果中有实体类不需要的列。
                        continue;
                    }
                    if (Modifier.isStatic(field.getModifiers())
                            || Modifier.isFinal(field.getModifiers())) {
                        throw new IllegalArgumentException("不能映射静态或 final 字段:" + fieldName);
                    }

                    field.setAccessible(true);
                    Object value = rs.getObject(i);
                    field.set(obj, convertValue(value, field.getType()));
                }
                list.add(obj);
            }
        }
        return list;
    } catch (SQLException | ReflectiveOperationException e) {
        // 查询失败或构造、赋值失败,都保留具体原因。
        throw new RuntimeException("查询或对象封装失败:" + sql, e);
    }
}

找不到字段时使用 continue,与这个工具类的宽松映射思路一致:例如结果有 extra 列,User 不需要,就跳过它。但标签写成 naem 也会被跳过,结果里的 name 可能仍是 null。项目中若要求严格映射,可将这个分支改为抛出异常,而不是忽略。

只有匹配到字段才做赋值。静态字段属于整个类,不能当成每一行的对象数据;final 字段也不适合这样填充,所以这里明确拒绝。

2.4.10 观察同一套查询创建不同对象

在补齐第 2.5、2.6 节方法后运行:

java 复制代码
List<User> users = jdbc.query(
    "SELECT id, name FROM test1 WHERE id > ? ORDER BY id", User.class, 0
);
System.out.println(users);
text 复制代码
[User(1, 张三), User(2, 李四), User(3, 王五)]

换一种目标类型:

java 复制代码
List<Student> students = jdbc.query(
    "SELECT id, name FROM student WHERE id > ? ORDER BY id", Student.class, 0
);
System.out.println(students);
text 复制代码
[Student(1, 小明), Student(2, 小红)]

变的是 SQL 和 Class 参数。query() 没有写死 User 或 Student,反射在运行时选构造方法、查字段,这就是它在这里的用途。

2.4.11 再看一次驼峰映射与父类字段

为了验证两个辅助方法的作用,增加一个小类型:继承 User 的 id,只新增 userName 字段。

java 复制代码
public class NamedUser extends User {
    private String userName;

    public NamedUser() {
    }

    @Override
    public String toString() {
        return super.toString() + ", userName=" + userName;
    }
}

查询时用别名模拟数据库常见的下划线名称:

java 复制代码
NamedUser user = jdbc.queryOne(
    "SELECT id, name AS user_name FROM test1 WHERE id = ?",
    NamedUser.class,
    1
);
System.out.println(user);

queryOne() 在第 2.7 节补齐,输出是:

text 复制代码
User(1, null), userName=张三

这里 id 在 NamedUser 中找不到,于是 findField() 去父类 User 找;user_name 经 toCamelCase() 变成 userName,填到子类字段。父类的 name 没有对应结果列,所以仍为 null。这个例子只为观察映射,不代表日常业务一定要这样继承。

如果真实表叫 users,列叫 user_name,而我们想填入 User 的 name,可以写 SELECT id, user_name AS name FROM users。别名决定映射目标,工具类不会猜测两种名字其实表示同一件事。

2.5 为什么还要转换类型

rs.getObject(i) 的返回类型由 SQL 类型和驱动决定,未必正好等于实体字段类型。比如数据库整数返回 Long,实体字段却是 Integer,直接赋值可能失败。

Field.set() 支持部分拆箱和扩大转换,但不会自动完成任意数字转换。因此,先把值转换成字段需要的类型,再赋值,更容易控制规则。

把下面的方法加入 JDBCUtils。它只处理当前例子需要的类型,不把简单查询写成完整的类型转换框架:

java 复制代码
private Object convertValue(Object value, Class<?> targetType) {
    if (value == null) {
        if (targetType.isPrimitive()) {
            // int 等基本类型不能接收 null,不能悄悄改成 0。
            throw new IllegalArgumentException("NULL 不能赋给基本类型:" + targetType.getName());
        }
        return null;
    }

    if (targetType.isInstance(value)) {
        return value; // 类型已经匹配,例如 String → String。
    }

    if (value instanceof Number n) {
        if (targetType == int.class || targetType == Integer.class) {
            // 精确转整数:有小数或超出 int 范围时抛异常。
            return new BigDecimal(n.toString()).intValueExact();
        }
        if (targetType == long.class || targetType == Long.class) {
            return new BigDecimal(n.toString()).longValueExact();
        }
        if (targetType == double.class || targetType == Double.class) {
            return n.doubleValue();
        }
    }

    if (targetType == String.class) {
        return value.toString();
    }
    throw new IllegalArgumentException(
        "不支持的类型转换:" + value.getClass().getName() + " -> " + targetType.getName()
    );
}

这几种情况分别是:

读取到的值 目标字段类型 处理结果
Integer 值 10 Integer 已兼容,直接使用
Integer 值 10 int 得到可用于拆箱赋值的整数
Long 值 10 Integer 精确转换为 10
Integer 值 10 long 精确转换为长整数
字符串 张三 String 直接使用
null String 或 Integer 保留 null
null int 拒绝赋值,抛异常

普通 Number.intValue()、longValue() 可以转换数字,但也可能截去小数或发生溢出;这里使用精确转换,避免把数据库值悄悄改错。doubleValue() 仍可能丢失精度,金额不要随意转成 double。

例如查询 COUNT(*) AS id 时,驱动可能返回较宽的整数类型,数值在范围内就能映射到 Integer。查询 1.5 AS id 时则不应擅自变成 1,而会在精确转换时失败。

这个版本对已经兼容的 BigDecimal 等引用值可以直接赋值,但没有提供日期到 LocalDate、枚举、布尔值和所有基本类型的通用转换规则。需要这些类型时,应明确增加对应规则,或者直接使用 JDBC 的类型化读取方式;不能把它当成适用于任意实体的完整 ORM。

2.6 统一绑定参数

每个 ? 都要按顺序获得一个值,所以把这段重复操作提取为 setParams()。将下面的方法加入 JDBCUtils:

java 复制代码
private void setParams(PreparedStatement ps, Object... params)
        throws SQLException {
    if (params == null) {
        return;
    }
    for (int i = 0; i < params.length; i++) {
        // Java 数组从 0 开始,JDBC 参数编号从 1 开始。
        ps.setObject(i + 1, params[i]);
    }
}

比如调用:

java 复制代码
List<User> users = jdbc.query(
    "SELECT id, name FROM test1 WHERE id > ? AND name = ? ORDER BY id",
    User.class,
    10,
    "张三"
);

实际绑定过程就是:

text 复制代码
params[0] 是 10     → ps.setObject(1, 10)
params[1] 是 "张三" → ps.setObject(2, "张三")

使用初始数据,这次结果为空:张三的编号是 1,不满足 id > 10。这里是为了观察两个参数怎么对应;把 10 换成 0,就能查询到张三。

参数少了或多了,驱动通常会报错。SQL NULL 还需要区分两件事:要绑定一个空值,可以传 (Object) null;传 (Object[]) null 表示整个参数数组为空,这个方法会跳过绑定。

setObject() 是简单的统一写法。跨数据库或遇到无法推断的类型时,可以明确使用 setInt()、setString()、setNull(编号, Types.VARCHAR) 等方法。绑定空值也不改变 SQL 的比较规则:WHERE name = ? 绑定 null 不等于 IS NULL。

2.7 只取一条记录

queryOne() 不需要重新写 JDBC 和反射流程,复用 query() 再取第一项即可。将下面的方法加入 JDBCUtils:

java 复制代码
public <T> T queryOne(String sql, Class<T> clazz, Object... params) {
    List<T> list = query(sql, clazz, params);
    return list.isEmpty() ? null : list.get(0);
}

调用:

java 复制代码
User user = jdbc.queryOne(
    "SELECT id, name FROM test1 WHERE id = ?", User.class, 1
);
System.out.println(user); // User(1, 张三)

查不到时返回 null,调用者使用前要判断。这里按主键查询,最多一行,不会出现“选哪一条”的问题。

当前实现先封装所有匹配行,再取第一项。如果 SQL 可能匹配很多行但只需要一条,可以在 MySQL SQL 中使用 ORDER BY ... LIMIT 1。需要唯一结果的业务则应检查多行情况,不能靠取第一条掩盖重复数据。

2.8 为什么要自动关闭资源

数据库连接和结果集占用资源,方法用完就应该释放,不应一直留到程序退出。

try-with-resources 会在正常返回和发生异常时,都尝试关闭已成功创建的资源。看看第 2.3 节的方法:

java 复制代码
try (Connection conn = getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    setParams(ps, params);
    return ps.executeUpdate();
}

即使代码执行了 return,离开 try 前仍会先关闭执行对象和连接。若 prepareStatement() 创建失败,已经成功创建的连接也会尝试关闭。

查询时还多一层 ResultSet,按嵌套结构退出,顺序是 结果集 → 执行对象 → 连接。因此,第 2.4 节已经在关闭前把数据复制到实体对象,再返回列表;不要将依赖已关闭资源的结果集直接返回给调用者。

使用连接池时,Connection.close() 通常是把连接归还池中,仍然要调用,不能因为“连接池会管理”就省掉。这个工具类直接使用 DriverManager,本身没有创建连接池。

2.8.1 自动关闭不等于事务已经处理好

Connection 提供 setAutoCommit(false)、commit()、rollback(),用于把多步修改放进同一个事务。关闭资源不能代替正确的提交和回滚;遇到业务失败时,应该明确回滚。

这个工具类每次 execute() 都获取自己的连接。 因此连续调用两次 jdbc.execute(),不能自动构成一次转账事务。要把多步操作放到同一个事务,必须让它们共享同一个 Connection,再一起提交或回滚。

也不要把一条 Connection 长期放进工具类让所有线程共用。连接上的事务状态会互相影响,正式项目通常借助数据源和事务管理来控制连接的使用范围。

2.9 从头到尾走一次查询

把 JDBCUtils 的连接外壳和 execute、query、toCamelCase、findField、convertValue、setParams、queryOne 方法补齐后,运行:

java 复制代码
List<User> users = jdbc.query(
    "SELECT id, name FROM test1 WHERE id > ? ORDER BY id", User.class, 0
);
System.out.println(users);

按照初始三条数据,过程是:

  1. 接收 SQL、User.class 和参数 0。
  2. 建立连接,绑定第一个 ?,查询到三行。
  3. 通过 ResultSetMetaData 得知有两列,标签为 id、name。
  4. 读第一行,反射调用 User 的无参构造方法,创建一个对象。
  5. 找到 User 的 id 字段,转换并写入 1;找到 name 字段,写入张三。
  6. 把这个对象加入列表,对后两行分别创建对象并赋值。
  7. 关闭 JDBC 资源,返回包含三个 User 对象的列表。
text 复制代码
[User(1, 张三), User(2, 李四), User(3, 王五)]

如果只查编号 1、2,就会得到两个对象:

java 复制代码
List<User> twoUsers = jdbc.query(
    "SELECT id, name FROM test1 WHERE id IN (?, ?) ORDER BY id",
    User.class, 1, 2
);
System.out.println(twoUsers);
text 复制代码
[User(1, 张三), User(2, 李四)]

这里真正值得理解的是 Class 决定创建谁,列标签决定填哪个字段,当前行的值决定填什么。列表、泛型和反射各自完成一小步,组合后才得到对象查询工具。

2.10 回顾:每个 API 在这次查询里做了什么

知识点 对应到这次查询
JDBC 连接 MySQL 并发送 SQL
PreparedStatement 固定 SQL 结构,绑定编号参数
ResultSet.next() 移到下一行
ResultSetMetaData 告诉工具类结果有 id、name 两列
泛型 <T> 让查询结果保持 User 或 Student 的类型关系
Class<T> 提供目标类的类型信息
getDeclaredConstructor().newInstance() 为当前行创建目标对象
getDeclaredField() 按名字寻找字段,找不到时再检查父类
Field.setAccessible(true) 在访问规则允许时操作私有字段
Field.getType() 与类型转换 确认目标字段需要哪一种值
Field.set(obj, value) 把当前列的值写入当前行对应的对象
try-with-resources 使用完毕后尝试关闭数据库资源

这个工具类是为了看懂 JDBC、泛型和反射如何协作。它没有完善的复杂类型转换、关联对象加载、映射缓存或事务管理,也不会自动调用 setter 校验。理解这些边界后,再学习成熟框架,会更容易知道框架替我们做了哪些工作。

2.11 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000