MiniSpring框架学习-JDBC 访问框架:如何抽取 JDBC 模板并隔离数据库?
- [13. JDBC 访问框架:如何抽取 JDBC 模板并隔离数据库?](#13. JDBC 访问框架:如何抽取 JDBC 模板并隔离数据库?)
-
- [一、先看原始 JDBC 有哪些步骤](#一、先看原始 JDBC 有哪些步骤)
- [二、JDBC 为什么能连接不同数据库?](#二、JDBC 为什么能连接不同数据库?)
- [三、原始 JDBC 的问题在哪里?](#三、原始 JDBC 的问题在哪里?)
- 四、用三个回调接口描述变化点
-
- [1. StatementCallback](#1. StatementCallback)
- [2. PreparedStatementCallback](#2. PreparedStatementCallback)
- [3. RowMapper](#3. RowMapper)
- 五、JdbcTemplate:把固定流程收回来
-
- [1. 注入 DataSource](#1. 注入 DataSource)
- [2. 执行普通 Statement 回调](#2. 执行普通 Statement 回调)
- [3. 执行 PreparedStatement 回调](#3. 执行 PreparedStatement 回调)
- [4. 用 RowMapper 完成常见查询](#4. 用 RowMapper 完成常见查询)
- [5. 更新操作](#5. 更新操作)
- [6. 参数绑定和基础校验](#6. 参数绑定和基础校验)
- [7. 统一转换 SQLException](#7. 统一转换 SQLException)
- [六、业务层只保留 SQL 和映射逻辑](#六、业务层只保留 SQL 和映射逻辑)
- [七、再用 DataSource 隔离连接创建](#七、再用 DataSource 隔离连接创建)
-
- [SingleConnectionDataSource 的核心实现](#SingleConnectionDataSource 的核心实现)
- [八、把 DataSource 和 JdbcTemplate 交给 IoC 容器](#八、把 DataSource 和 JdbcTemplate 交给 IoC 容器)
- 九、串起完整调用流程
- 十、测试和当前实现边界
教程: https://github.com/YaleGuo/minis
极客时间: 手把手带你写一个 MiniSpring
前言:这节内容原教程写的还挺好的,逐层递进,清晰易懂。可能也是Spring的设计思想贯穿始终,前面IOC,MVC的设计思路和JDBC都是共通的,都是接口定义能力,抽象类定义模版,具体类负责实现。职责单一理念贯穿框架设计始终。感觉我有点被熏入味,看的轻松点了。当然这节学完最大的收获是知道JDBC(Java Database Connectivity Java提供的标准 API)提供的标准能力接口和具体数据库厂家之间提供的服务之间如何解耦的。
13. JDBC 访问框架:如何抽取 JDBC 模板并隔离数据库?
这一节分成两部分:
- 先通过原始 JDBC,理解 JDBC API、数据库驱动和
DriverManager的分工。 - 再实现 MiniSpring 的
JdbcTemplate,把重复流程抽成模板,并用DataSource隔离连接细节。
这里说的"隔离数据库",主要是隔离驱动加载、URL、用户名、密码以及连接创建方式。不同数据库之间的 SQL 差异,并不会因为使用了 DataSource 就自动消失。
一、先看原始 JDBC 有哪些步骤
为了和后文统一,示例都使用下面这张 users 表:
sql
create table if not exists users (
id int primary key auto_increment,
name varchar(100) not null,
birthday date null
);
查询一条用户数据,原始 JDBC 大致要写成这样:
java
package com.chenhai.test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class JdbcDemo {
public static void main(String[] args) throws Exception {
String url = "jdbc:mysql://localhost:3306/test"
+ "?useSSL=false&serverTimezone=Asia/Shanghai";
String username = "root";
String password = System.getenv("MYSQL_PASSWORD");
// 显式加载驱动。现代 JDBC 通常可以自动发现驱动,
// 这里保留它,便于观察驱动注册过程并尽早发现配置错误。
Class.forName("com.mysql.cj.jdbc.Driver");
String sql = "select id, name, birthday from users where id = ?";
// try-with-resources 会按声明的相反顺序自动关闭 JDBC 资源。
try (Connection connection =
DriverManager.getConnection(url, username, password);
PreparedStatement statement = connection.prepareStatement(sql)) {
// JDBC 参数下标从 1 开始。
statement.setInt(1, 1);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
int id = resultSet.getInt("id");
String name = resultSet.getString("name");
java.sql.Date birthday = resultSet.getDate("birthday");
System.out.println(id + " - " + name + " - " + birthday);
}
}
}
}
}
完整流程可以概括为:
text
加载或发现数据库驱动
↓
获取 Connection
↓
创建 Statement / PreparedStatement
↓
绑定参数并执行 SQL
↓
读取 ResultSet
↓
关闭 ResultSet、Statement、Connection
几个核心接口的职责如下:
| 接口 | 主要职责 |
|---|---|
Connection |
表示一次数据库连接,也负责创建 SQL 执行器 |
Statement |
执行不带占位符的 SQL |
PreparedStatement |
预编译 SQL、绑定参数并执行,是业务代码的常用选择 |
ResultSet |
保存查询结果,游标初始位于第一行之前 |
Driver |
由数据库厂商实现,用来识别 URL 并创建连接 |
PreparedStatement 把 SQL 结构和参数值分开,可以避免把普通参数直接拼进 SQL,从而防止常见的 SQL 注入。不过表名、列名、排序方向等 SQL 结构不能通过 ? 绑定,仍然需要白名单校验。
二、JDBC 为什么能连接不同数据库?
Java 官方定义 JDBC API,数据库厂商提供对应实现:
text
应用程序
|
v
JDBC API:Connection、Statement、ResultSet、Driver
|
v
DriverManager
|
+----------+-----------+
| | |
v v v
MySQL Driver Oracle Driver SQL Server Driver
| | |
v v v
MySQL Oracle SQL Server
应用程序面向 JDBC 接口编程,不直接依赖驱动内部类。数据库厂商负责实现 java.sql.Driver,并提供能够连接自家数据库的 Connection、Statement 等具体对象。
在较早的 JDBC 用法中:
java
Class.forName("com.mysql.cj.jdbc.Driver");
会触发驱动类初始化,驱动再把自己注册到 DriverManager。从 JDBC 4 开始,驱动通常会通过 Java SPI 声明 java.sql.Driver 实现,JDBC 可以自动发现它,因此现代项目不一定要手动调用 Class.forName(...)。
MiniSpring 的教学实现仍然显式加载驱动,目的不是说现代 JDBC 必须这样做,而是为了在容器启动阶段尽早暴露"驱动类不存在"这类配置错误。
这种设计在思想上类似桥接:上层依赖稳定接口,下层实现可以替换。不过更准确地说,JDBC 使用的是"标准 API + 厂商驱动 + SPI 自动发现"的插件式设计,而不是严格照搬 GoF 桥接模式的类结构。
三、原始 JDBC 的问题在哪里?
回头看原始代码,会发现大部分步骤都很稳定:
text
获取连接
创建 Statement
绑定参数
执行 SQL
关闭资源
处理 SQLException
真正会随业务变化的内容主要是:
text
执行哪条 SQL
传入哪些参数
怎样把 ResultSet 转成业务对象
所以可以把 JDBC 访问过程拆成两部分:
| 固定流程,由框架负责 | 变化逻辑,由业务代码提供 |
|---|---|
| 获取和关闭连接 | SQL |
| 创建和关闭 Statement | SQL 参数 |
| 参数绑定 | 执行查询还是更新 |
| 遍历和关闭 ResultSet | 一行数据怎样映射成对象 |
| 转换 SQLException | 最终返回什么类型 |
最直接的想法是把固定流程放进抽象类,再让子类实现 doInStatement(...)。这种方式能工作,但每种查询都可能要写一个新的子类,类会越来越多。
MiniSpring 最终采用回调:JdbcTemplate 保留固定流程,调用者把变化的行为以 lambda 或匿名类的形式传进来。模板不一定非要写成抽象类,关键是固定流程只保留一份,变化点能够从外部注入。
四、用三个回调接口描述变化点
1. StatementCallback
StatementCallback 把普通 Statement 交给调用者,适合演示底层执行过程:
java
package com.chenhai.jdbc.core;
import java.sql.SQLException;
import java.sql.Statement;
@FunctionalInterface
public interface StatementCallback<T> {
T doInStatement(Statement statement) throws SQLException;
}
T 表示回调最终返回的类型。查询数量时可以返回 Integer,查询用户时可以返回 User。
2. PreparedStatementCallback
需要绑定参数时,使用 PreparedStatementCallback:
java
package com.chenhai.jdbc.core;
import java.sql.PreparedStatement;
import java.sql.SQLException;
@FunctionalInterface
public interface PreparedStatementCallback<T> {
T doInPreparedStatement(PreparedStatement statement) throws SQLException;
}
JdbcTemplate 会先创建 PreparedStatement 并绑定参数,回调只负责执行它和处理结果。
3. RowMapper
大多数查询并不需要控制整个 PreparedStatement,只需要说明"一行数据怎样变成一个对象"。因此再抽出 RowMapper:
java
package com.chenhai.jdbc.core;
import java.sql.ResultSet;
import java.sql.SQLException;
@FunctionalInterface
public interface RowMapper<T> {
T mapRow(ResultSet resultSet, int rowNum) throws SQLException;
}
这里的 ResultSet 已经移动到当前行,RowMapper 不需要再调用 next()。rowNum 从 0 开始。
三个接口的粒度不同:
text
StatementCallback
└─ 调用者控制普通 Statement 的执行和结果处理
PreparedStatementCallback
└─ 模板负责创建和绑定参数,调用者控制执行和结果处理
RowMapper
└─ 模板负责执行、遍历和关闭 ResultSet,调用者只映射当前行
五、JdbcTemplate:把固定流程收回来
组件关系如下:
text
UserService
|
| SQL、参数、RowMapper
v
JdbcTemplate
|
| 获取连接
v
DataSource <|-- SingleConnectionDataSource
JdbcTemplate --> StatementCallback
JdbcTemplate --> PreparedStatementCallback
JdbcTemplate --> RowMapper
1. 注入 DataSource
JdbcTemplate 不保存 URL、用户名和密码,只依赖标准的 DataSource 接口:
java
public class JdbcTemplate {
private DataSource dataSource;
public JdbcTemplate() {
}
public JdbcTemplate(DataSource dataSource) {
this.dataSource = dataSource;
}
public DataSource getDataSource() {
return dataSource;
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
}
无参构造器和 setDataSource(...) 用于 XML 属性注入;带参构造器方便测试代码直接创建对象。
2. 执行普通 Statement 回调
java
public <T> T query(StatementCallback<T> callback) {
return execute(callback);
}
public <T> T execute(StatementCallback<T> callback) {
if (callback == null) {
throw new IllegalArgumentException(
"StatementCallback must not be null");
}
try (Connection connection = obtainConnection();
Statement statement = connection.createStatement()) {
return callback.doInStatement(statement);
} catch (SQLException e) {
throw new JdbcException("JDBC operation failed", e);
}
}
模板负责关闭 Connection 和 Statement。如果回调自己创建了 ResultSet,回调也要用 try-with-resources 关闭它:
java
Integer count = jdbcTemplate.query(statement -> {
try (ResultSet resultSet =
statement.executeQuery("select count(*) from users")) {
resultSet.next();
return resultSet.getInt(1);
}
});
lambda 捕获的局部变量必须是 final 或 effectively final,也就是初始化后没有再被赋值。不是说 lambda 的参数必须加 final。
3. 执行 PreparedStatement 回调
java
public <T> T query(String sql,
Object[] args,
PreparedStatementCallback<T> callback) {
return execute(sql, args, callback);
}
public <T> T execute(String sql,
Object[] args,
PreparedStatementCallback<T> callback) {
assertSql(sql);
if (callback == null) {
throw new IllegalArgumentException(
"PreparedStatementCallback must not be null");
}
try (Connection connection = obtainConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
bindParameters(statement, args);
return callback.doInPreparedStatement(statement);
} catch (SQLException e) {
throw new JdbcException(
"JDBC operation failed for SQL: " + sql, e);
}
}
这里固定了四件事:
- 校验 SQL;
- 从
DataSource获取连接; - 创建
PreparedStatement并绑定参数; - 关闭资源并统一转换异常。
4. 用 RowMapper 完成常见查询
java
public <T> List<T> query(String sql,
Object[] args,
RowMapper<T> rowMapper) {
if (rowMapper == null) {
throw new IllegalArgumentException("RowMapper must not be null");
}
return execute(sql, args, statement -> {
List<T> rows = new ArrayList<>();
try (ResultSet resultSet = statement.executeQuery()) {
int rowNum = 0;
while (resultSet.next()) {
rows.add(rowMapper.mapRow(resultSet, rowNum++));
}
}
return rows;
});
}
public <T> T queryForObject(String sql,
Object[] args,
RowMapper<T> rowMapper) {
List<T> rows = query(sql, args, rowMapper);
return rows.isEmpty() ? null : rows.get(0);
}
query(...) 负责遍历并关闭 ResultSet,业务代码只负责当前行映射。
当前教学版 queryForObject(...) 的规则是:没有数据返回 null,有数据返回第一行;即使查出多行,也只取第一行。它和 Spring JDBC 的严格单行语义并不完全相同。
5. 更新操作
java
public int update(String sql, Object[] args) {
return execute(sql, args, PreparedStatement::executeUpdate);
}
public int update(String sql) {
return update(sql, new Object[0]);
}
executeUpdate() 返回受影响的行数,所以新增、修改、删除都可以复用这个入口。
6. 参数绑定和基础校验
java
private Connection obtainConnection() throws SQLException {
if (this.dataSource == null) {
throw new IllegalStateException(
"DataSource must be configured before using JdbcTemplate");
}
return this.dataSource.getConnection();
}
private void bindParameters(PreparedStatement statement,
Object[] args) throws SQLException {
Object[] parameters = args == null ? new Object[0] : args;
for (int i = 0; i < parameters.length; i++) {
Object value = parameters[i];
int parameterIndex = i + 1;
// 常用类型显式绑定,其余类型交给 JDBC 驱动处理。
if (value instanceof String) {
statement.setString(parameterIndex, (String) value);
} else if (value instanceof Integer) {
statement.setInt(parameterIndex, (Integer) value);
} else if (value instanceof Long) {
statement.setLong(parameterIndex, (Long) value);
} else if (value instanceof Boolean) {
statement.setBoolean(parameterIndex, (Boolean) value);
} else if (value instanceof java.sql.Date) {
statement.setDate(parameterIndex, (java.sql.Date) value);
} else if (value instanceof java.util.Date) {
statement.setTimestamp(
parameterIndex,
new Timestamp(((java.util.Date) value).getTime()));
} else {
statement.setObject(parameterIndex, value);
}
}
}
private void assertSql(String sql) {
if (sql == null || sql.trim().isEmpty()) {
throw new IllegalArgumentException("SQL must not be empty");
}
}
注意两个细节:
- JDBC 参数位置从
1开始,所以代码使用i + 1。 null会进入setObject(...)。这是教学版的简化处理,更完整的框架还会结合 SQL 类型调用setNull(...)。
7. 统一转换 SQLException
业务层不需要反复捕获受检异常 SQLException。模板在 JDBC 资源边界处把它转换成运行时异常,同时保留原始异常:
java
package com.chenhai.jdbc;
public class JdbcException extends RuntimeException {
public JdbcException(String message, Throwable cause) {
super(message, cause);
}
}
这里还没有像 Spring 那样根据错误码细分异常类型,只完成了最基础的统一封装。
六、业务层只保留 SQL 和映射逻辑
UserService 不再关心驱动加载、连接创建和资源关闭:
java
package com.chenhai.jdbc.example;
import com.chenhai.beans.factory.annotation.Autowired;
import com.chenhai.jdbc.core.JdbcTemplate;
import java.sql.Date;
public class UserService {
// 当前 MiniSpring 按字段名查找 Bean,
// 所以 XML 中必须存在 id="jdbcTemplate"。
@Autowired
private JdbcTemplate jdbcTemplate;
public User getUserInfo(int userId) {
final String sql =
"select id, name, birthday from users where id = ?";
return jdbcTemplate.queryForObject(
sql,
new Object[]{userId},
(resultSet, rowNum) -> {
User user = new User();
user.setId(resultSet.getInt("id"));
user.setName(resultSet.getString("name"));
Date birthday = resultSet.getDate("birthday");
if (birthday != null) {
user.setBirthday(
new java.util.Date(birthday.getTime()));
}
return user;
});
}
public int updateUserName(int userId, String name) {
final String sql =
"update users set name = ? where id = ?";
return jdbcTemplate.update(sql, new Object[]{name, userId});
}
}
现在业务层只需要回答三个问题:
- 执行什么 SQL?
- 参数是什么?
- 一行数据怎样转成
User?
其余步骤都交给 JdbcTemplate。
七、再用 DataSource 隔离连接创建
如果 JdbcTemplate 内部直接写:
java
Class.forName("com.mysql.cj.jdbc.Driver");
DriverManager.getConnection(url, username, password);
模板仍然和具体数据库配置绑在一起。更合理的方式是依赖 Java 标准接口 javax.sql.DataSource:
text
JdbcTemplate
|
v
DataSource 接口
|
+-- 教学版 SingleConnectionDataSource
+-- HikariCP
+-- Druid
+-- 其他连接池或容器数据源
JdbcTemplate 只调用:
java
Connection connection = dataSource.getConnection();
至于连接从 DriverManager 新建、从连接池借出,还是由应用服务器提供,模板都不需要知道。
SingleConnectionDataSource 的核心实现
下面只保留和连接创建有关的主线。完整类还实现了 DataSource 的日志、超时和包装器方法:
java
public class SingleConnectionDataSource implements DataSource {
private String driverClassName;
private String url;
private String username;
private String password;
private Properties connectionProperties;
public void setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
try {
// 在配置阶段加载驱动,尽早发现驱动缺失。
Class.forName(driverClassName);
} catch (ClassNotFoundException e) {
throw new IllegalStateException(
"Could not load JDBC driver class ["
+ driverClassName + "]",
e);
}
}
public void setUrl(String url) {
this.url = url;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setConnectionProperties(
Properties connectionProperties) {
this.connectionProperties = connectionProperties;
}
@Override
public Connection getConnection() throws SQLException {
return getConnectionFromDriver(this.username, this.password);
}
@Override
public Connection getConnection(String username,
String password) throws SQLException {
return getConnectionFromDriver(username, password);
}
protected Connection getConnectionFromDriver(
String username,
String password) throws SQLException {
Properties mergedProperties = new Properties();
if (this.connectionProperties != null) {
mergedProperties.putAll(this.connectionProperties);
}
if (username != null) {
mergedProperties.setProperty("user", username);
}
if (password != null) {
mergedProperties.setProperty("password", password);
}
return getConnectionFromDriverManager(
this.url, mergedProperties);
}
protected Connection getConnectionFromDriverManager(
String url,
Properties properties) throws SQLException {
if (url == null || url.trim().isEmpty()) {
throw new SQLException("JDBC url must be configured");
}
return DriverManager.getConnection(url, properties);
}
// DataSource 的其他标准方法见项目完整实现。
}
这个类的名字沿用了教程中的 SingleConnectionDataSource,但当前实现并不会长期保存同一个连接。每次调用 getConnection(),都会通过 DriverManager 创建新的物理连接,因此 JdbcTemplate 可以正常关闭它。
它不是连接池,只适合教学。生产环境可以换成 HikariCP、Druid 等连接池,而 JdbcTemplate 不需要修改,这才是依赖 DataSource 接口的价值。
八、把 DataSource 和 JdbcTemplate 交给 IoC 容器
在 applicationContext.xml 中注册对象和依赖关系:
xml
<bean id="dataSource"
class="com.chenhai.jdbc.datasource.SingleConnectionDataSource">
<property type="String"
name="driverClassName"
value="com.mysql.cj.jdbc.Driver"/>
<property type="String"
name="url"
value="jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:test}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true"/>
<property type="String"
name="username"
value="${MYSQL_USER:root}"/>
<property type="String"
name="password"
value="${MYSQL_PASSWORD:}"/>
</bean>
<bean id="jdbcTemplate"
class="com.chenhai.jdbc.core.JdbcTemplate">
<property type="javax.sql.DataSource"
name="dataSource"
ref="dataSource"/>
</bean>
<bean id="userService"
class="com.chenhai.jdbc.example.UserService"/>
这里有两种注入:
dataSource通过 XML 的ref注入JdbcTemplate.setDataSource(...)。UserService中的jdbcTemplate字段通过@Autowired注入。
当前 MiniSpring 的 @Autowired 是按字段名找 Bean,不是按类型查找。因此字段名是 jdbcTemplate 时,XML 中的 Bean ID 也必须是 jdbcTemplate。
项目中的 XmlBeanDefinitionReader 还增加了占位符解析:
text
${NAME}
${NAME:defaultValue}
解析顺序是:
text
Java 系统属性
↓ 没找到
同名环境变量
↓ 还没找到
默认值
PowerShell 中可以这样提供数据库配置:
powershell
$env:MYSQL_HOST = "localhost"
$env:MYSQL_PORT = "3306"
$env:MYSQL_USER = "root"
$env:MYSQL_PASSWORD = "<your-password>"
$env:MYSQL_DATABASE = "test"
密码不应该直接写进 Java 源码、XML 或测试代码。
九、串起完整调用流程
调用:
java
userService.getUserInfo(1);
实际经过的流程是:
text
UserService.getUserInfo(1)
↓
准备 SQL、参数和 RowMapper
↓
JdbcTemplate.queryForObject(...)
↓
JdbcTemplate.query(...)
↓
JdbcTemplate.execute(...)
↓
DataSource.getConnection()
↓
创建 PreparedStatement
↓
绑定 userId
↓
执行查询并遍历 ResultSet
↓
RowMapper 把当前行转成 User
↓
关闭 ResultSet、PreparedStatement、Connection
↓
返回 User
这条链路中,各层职责比较清楚:
| 组件 | 职责 |
|---|---|
UserService |
SQL、参数和业务对象映射 |
JdbcTemplate |
JDBC 固定流程、资源管理和异常转换 |
RowMapper |
当前行到对象的转换 |
DataSource |
提供数据库连接 |
| 数据库驱动 | 实现 JDBC 接口并连接具体数据库 |
十、测试和当前实现边界
项目使用 H2 内存数据库测试 JdbcTemplate,不依赖外部 MySQL 的网络和数据状态,主要验证:
PreparedStatement参数绑定;RowMapper查询;update(...)返回受影响行数;StatementCallback回调;- XML 配置和
@Autowired注入。
当前版本仍然是教学实现,还没有包含:
- 事务管理;
- 连接池;
- 批量操作;
- 自动生成主键回填;
- 完整的 SQL 类型系统;
- Spring JDBC 那样细分的异常体系;
- 严格的单行查询结果校验。
这些能力可以继续增加,但不会改变这一节的核心思路:
用
JdbcTemplate收拢重复流程,用回调保留业务变化,用DataSource隔离连接创建,再由 IoC 容器组装各个组件。