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 不会自动消除这些差异。
一次数据库访问通常有六步:
- 将驱动加入项目依赖。
- 获取
Connection。 - 创建
Statement或PreparedStatement。 - 执行 SQL。
- 有查询结果时,读取
ResultSet。 - 关闭结果集、执行对象和连接。
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);
按照初始三条数据,过程是:
- 接收 SQL、
User.class和参数 0。 - 建立连接,绑定第一个
?,查询到三行。 - 通过
ResultSetMetaData得知有两列,标签为id、name。 - 读第一行,反射调用 User 的无参构造方法,创建一个对象。
- 找到 User 的 id 字段,转换并写入 1;找到 name 字段,写入张三。
- 把这个对象加入列表,对后两行分别创建对象并赋值。
- 关闭 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 校验。理解这些边界后,再学习成熟框架,会更容易知道框架替我们做了哪些工作。

评论