MiniSpring框架学习笔记-JDBC 访问框架:如何抽取专门的部件完成专门的任务?

MiniSpring框架学习-JDBC 访问框架:如何抽取专门的部件完成专门的任务?

  • [14. 增强模板:如何抽取专门的部件完成专门的任务?](#14. 增强模板:如何抽取专门的部件完成专门的任务?)
    • 一、先看最终使用方式
    • [二、参数绑定交给 ArgumentPreparedStatementSetter](#二、参数绑定交给 ArgumentPreparedStatementSetter)
    • 三、结果处理拆成两层
      • [1. RowMapper:只管当前行](#1. RowMapper:只管当前行)
      • [2. ResultSetExtractor:处理整个结果集](#2. ResultSetExtractor:处理整个结果集)
      • [3. RowMapperResultSetExtractor:把两者接起来](#3. RowMapperResultSetExtractor:把两者接起来)
    • [四、JdbcTemplate 现在怎样串起来](#四、JdbcTemplate 现在怎样串起来)
      • [1. 执行 PreparedStatement 回调](#1. 执行 PreparedStatement 回调)
      • [2. 用 ResultSetExtractor 处理整个结果集](#2. 用 ResultSetExtractor 处理整个结果集)
      • [3. 用 RowMapper 做最常见的列表查询](#3. 用 RowMapper 做最常见的列表查询)
      • [4. 单行查询和更新](#4. 单行查询和更新)
    • 五、再把连接创建换成连接池
      • [1. PooledConnection:逻辑连接](#1. PooledConnection:逻辑连接)
      • [2. PooledDataSource:池化 DataSource](#2. PooledDataSource:池化 DataSource)
    • [六、交给 IoC 容器装配](#六、交给 IoC 容器装配)
    • 七、完整调用链
    • 八、当前实现边界

教程: https://github.com/YaleGuo/minis

极客时间: 手把手带你写一个 MiniSpring

前言:这节内容比较简单,是对上一节内容的完善,针对专门功能的抽象以及增强。

14. 增强模板:如何抽取专门的部件完成专门的任务?

上一节我们已经有了一个能用的 JdbcTemplate:业务代码传入 SQL、参数和回调,模板负责获取连接、创建 PreparedStatement、关闭资源和转换异常。

这一节继续往前走一步:把 JdbcTemplate 里还能拆开的专门任务,交给专门的部件。

JDBC 执行 SQL 大体还是三件事:

text 复制代码
准备参数
    ↓
执行语句
    ↓
处理返回结果

上一节这些逻辑已经被收进 JdbcTemplate,但其中还有一些小职责可以继续拆出来:

职责 专门部件
PreparedStatement 绑定参数 ArgumentPreparedStatementSetter
把一行 ResultSet 转成对象 RowMapper
把整个 ResultSet 提取成结果 ResultSetExtractor
RowMapper 处理整个结果集 RowMapperResultSetExtractor
复用数据库连接 PooledDataSourcePooledConnection

这不是为了"类越多越好",而是为了让每个类只负责一件清楚的事。

一、先看最终使用方式

业务层最理想的样子,是只关心业务本身:执行什么 SQL,传什么参数,结果怎么变成 User

项目里的 UserService 已经变成这样:

java 复制代码
package com.chenhai.jdbc.example;

import com.chenhai.beans.factory.annotation.Autowired;
import com.chenhai.jdbc.core.JdbcTemplate;

import java.sql.Date;
import java.util.List;

/**
 * 演示业务层怎样使用 JdbcTemplate。
 *
 * UserService 不再负责加载驱动、创建连接或关闭资源,只保留 SQL 和结果映射逻辑。
 */
public class UserService {

    /**
     * 当前 MiniSpring 的 @Autowired 按字段名查找 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) -> mapUser(resultSet));
    }

    public List<User> getUsers(int minUserId) {
        final String sql = "select id, name, birthday from users where id > ?";
        return jdbcTemplate.query(sql, new Object[]{minUserId},
                (resultSet, rowNum) -> mapUser(resultSet));
    }

    public int updateUserName(int userId, String name) {
        final String sql = "update users set name = ? where id = ?";
        return jdbcTemplate.update(sql, new Object[]{name, userId});
    }

    private User mapUser(java.sql.ResultSet resultSet)
            throws java.sql.SQLException {
        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;
    }
}

从这个用法往下看,JdbcTemplate 至少要解决三件事:

  1. new Object[]{userId} 里的参数怎么绑定到 SQL 的 ? 上;
  2. ResultSet 怎么一行一行变成 User
  3. 每次查询时,连接怎么创建、复用和归还。

二、参数绑定交给 ArgumentPreparedStatementSetter

上一节参数绑定逻辑还在 JdbcTemplate 里。它能工作,但模板类会越来越杂。

java 复制代码
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 参数下标从 1 开始。常用类型显式设置,其余类型交给驱动的 setObject 处理。
        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);
        }
    }
}

参数绑定本身是一件独立的事:拿到 Object[] args,按顺序调用 JDBC 的 setXxx(...) 方法。于是项目里把它抽成了 ArgumentPreparedStatementSetter

java 复制代码
package com.chenhai.jdbc.core;

import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;

/**
 * 按顺序给 PreparedStatement 绑定参数。
 *
 * 业务代码只需要传入 Object[],类型判断和 JDBC 下标从 1 开始这些细节
 * 统一收口到这里。
 */
public class ArgumentPreparedStatementSetter {

    private final Object[] args;

    public ArgumentPreparedStatementSetter(Object[] args) {
        this.args = args;
    }

    public void setValues(PreparedStatement statement) throws SQLException {
        if (this.args == null) {
            return;
        }

        for (int i = 0; i < this.args.length; i++) {
            // Java 数组从 0 开始,JDBC 参数位置从 1 开始。
            doSetValue(statement, i + 1, this.args[i]);
        }
    }

    protected void doSetValue(PreparedStatement statement,
                              int parameterPosition,
                              Object argValue) throws SQLException {
        if (argValue == null) {
            statement.setNull(parameterPosition, Types.NULL);
        } else if (argValue instanceof String) {
            statement.setString(parameterPosition, (String) argValue);
        } else if (argValue instanceof Integer) {
            statement.setInt(parameterPosition, (Integer) argValue);
        } else if (argValue instanceof Long) {
            statement.setLong(parameterPosition, (Long) argValue);
        } else if (argValue instanceof Boolean) {
            statement.setBoolean(parameterPosition, (Boolean) argValue);
        } else if (argValue instanceof Double) {
            statement.setDouble(parameterPosition, (Double) argValue);
        } else if (argValue instanceof Float) {
            statement.setFloat(parameterPosition, (Float) argValue);
        } else if (argValue instanceof BigDecimal) {
            statement.setBigDecimal(parameterPosition, (BigDecimal) argValue);
        } else if (argValue instanceof java.sql.Date) {
            statement.setDate(parameterPosition, (java.sql.Date) argValue);
        } else if (argValue instanceof Timestamp) {
            statement.setTimestamp(parameterPosition, (Timestamp) argValue);
        } else if (argValue instanceof java.util.Date) {
            statement.setTimestamp(parameterPosition,
                    new Timestamp(((java.util.Date) argValue).getTime()));
        } else {
            statement.setObject(parameterPosition, argValue);
        }
    }
}

注意这里不是"自动拼 SQL"。PreparedStatement 的重点是把 SQL 结构和参数值分开:

java 复制代码
select id, name from users where id = ?

参数值通过 setXxx(...) 交给 JDBC 驱动处理,而不是直接拼成字符串。

三、结果处理拆成两层

查询结果处理也可以拆开看。

text 复制代码
ResultSetExtractor
    关注整个 ResultSet 怎么变成最终结果

RowMapper
    关注当前这一行怎么变成一个对象

RowMapperResultSetExtractor
    负责循环 ResultSet,每一行调用 RowMapper

它们的关系可以画成这样:

text 复制代码
JdbcTemplate
    |
    | executeQuery()
    v
ResultSet
    |
    | ResultSetExtractor.extractData(rs)
    v
RowMapperResultSetExtractor
    |
    | while (rs.next())
    v
RowMapper.mapRow(rs, rowNum)

1. RowMapper:只管当前行

java 复制代码
package com.chenhai.jdbc.core;

import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 把 ResultSet 当前行转换为领域对象。
 *
 * rowNum 从 0 开始,可用于需要区分首行和后续行的映射场景。
 */
@FunctionalInterface
public interface RowMapper<T> {

    T mapRow(ResultSet resultSet, int rowNum) throws SQLException;
}

RowMapper 不调用 resultSet.next()。模板已经把游标移动到当前行了,它只负责读字段、组装对象。

2. ResultSetExtractor:处理整个结果集

java 复制代码
package com.chenhai.jdbc.core;

import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * 把整个 ResultSet 转换成目标结果。
 *
 * RowMapper 关注"一行怎么映射",ResultSetExtractor 关注"整个结果集怎么提取"。
 */
@FunctionalInterface
public interface ResultSetExtractor<T> {

    T extractData(ResultSet resultSet) throws SQLException;
}

ResultSetExtractor 的粒度更大。它可以返回 List<User>,也可以返回统计值、分组结果、树形结构。

3. RowMapperResultSetExtractor:把两者接起来

java 复制代码
package com.chenhai.jdbc.core;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

/**
 * 把 RowMapper 适配成 ResultSetExtractor。
 *
 * 遍历 ResultSet 的固定流程只写一次,每一行的对象映射交给 RowMapper。
 */
public class RowMapperResultSetExtractor<T>
        implements ResultSetExtractor<List<T>> {

    private final RowMapper<T> rowMapper;

    public RowMapperResultSetExtractor(RowMapper<T> rowMapper) {
        if (rowMapper == null) {
            throw new IllegalArgumentException("RowMapper must not be null");
        }
        this.rowMapper = rowMapper;
    }

    @Override
    public List<T> extractData(ResultSet resultSet) throws SQLException {
        List<T> results = new ArrayList<>();
        int rowNum = 0;
        while (resultSet.next()) {
            results.add(this.rowMapper.mapRow(resultSet, rowNum++));
        }
        return results;
    }
}

这样业务代码就不需要反复写:

java 复制代码
while (resultSet.next()) {
    ...
}

它只写一行怎么转对象:

java 复制代码
jdbcTemplate.query(sql, new Object[]{minUserId},
        (resultSet, rowNum) -> mapUser(resultSet));

四、JdbcTemplate 现在怎样串起来

有了这些部件,JdbcTemplate 的代码反而更清楚。

1. 执行 PreparedStatement 回调

java 复制代码
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)) {
        // 参数绑定交给专门部件。
        new ArgumentPreparedStatementSetter(args).setValues(statement);

        // 执行 SQL 的变化点仍然交给回调。
        return callback.doInPreparedStatement(statement);
    } catch (SQLException e) {
        throw new JdbcException("JDBC operation failed for SQL: " + sql, e);
    }
}

2. 用 ResultSetExtractor 处理整个结果集

java 复制代码
public <T> T query(String sql,
                   Object[] args,
                   ResultSetExtractor<T> resultSetExtractor) {
    if (resultSetExtractor == null) {
        throw new IllegalArgumentException(
                "ResultSetExtractor must not be null");
    }

    return execute(sql, args, statement -> {
        try (ResultSet resultSet = statement.executeQuery()) {
            return resultSetExtractor.extractData(resultSet);
        }
    });
}

这里 ResultSetJdbcTemplate 关闭。业务代码不要把 ResultSet 保存到方法外面继续用。

3. 用 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 query(sql, args, new RowMapperResultSetExtractor<>(rowMapper));
}

这就是"组合"的味道:JdbcTemplate 不再自己写循环,而是把 RowMapper 包装成 ResultSetExtractor

4. 单行查询和更新

java 复制代码
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);
}

public int update(String sql, Object[] args) {
    return execute(sql, args, PreparedStatement::executeUpdate);
}

当前教学版的 queryForObject(...) 仍然是简化语义:没有结果返回 null,多行时取第一行。它还没有做 Spring JDBC 那种严格的单行校验。

现在 JdbcTemplate 的结构可以概括成:

text 复制代码
JdbcTemplate
    |
    +-- ArgumentPreparedStatementSetter
    |       负责参数绑定
    |
    +-- ResultSetExtractor
    |       负责整个结果集提取
    |
    +-- RowMapperResultSetExtractor
            负责把 RowMapper 应用到每一行

五、再把连接创建换成连接池

前面解决的是模板内部的职责拆分。接下来处理连接复用。

如果每次 SQL 都新建物理连接、用完就断开,成本很高。连接池的想法很朴素:

text 复制代码
启动或第一次使用时创建几个物理连接
        ↓
getConnection() 借出一个空闲连接
        ↓
业务代码照常使用 Connection
        ↓
connection.close() 不断开数据库,只把连接还给池

这一节的连接池是教学版,不是生产级连接池。它用来说明 DataSource 为什么有价值:JdbcTemplate 只依赖 DataSource,所以底层从普通连接换成池化连接时,模板和业务层都不用改。

1. PooledConnection:逻辑连接

PooledConnection 实现了 java.sql.Connection。它内部包着一个真实物理连接,并用 active 标记当前是否被借出。

关键点是 close()

java 复制代码
/**
 * close() 不关闭真实数据库连接,只把连接归还给池。
 */
@Override
public void close() throws SQLException {
    if (!this.active) {
        return;
    }

    try {
        if (!this.connection.getAutoCommit()) {
            this.connection.rollback();
            this.connection.setAutoCommit(true);
        }
        this.connection.clearWarnings();
    } finally {
        this.active = false;
        if (this.closeCallback != null) {
            this.closeCallback.run();
        }
    }
}

所以这里不是"逻辑删除连接"。更准确的说法是:

text 复制代码
active = true
    表示连接已借出

active = false
    表示连接已归还,可以再次借出

真正关闭物理连接,只能由连接池销毁时调用:

java 复制代码
public void closePhysicalConnection() throws SQLException {
    this.active = false;
    if (this.connection != null && !this.connection.isClosed()) {
        this.connection.close();
    }
}

其他 Connection 方法大多是委托给真实连接。为了防止连接归还后继续使用,内部会先检查状态:

java 复制代码
private Connection target() throws SQLException {
    if (this.connection == null) {
        throw new SQLException("No physical connection available");
    }
    if (!this.active) {
        throw new SQLException("Connection has been returned to the pool");
    }
    return this.connection;
}

2. PooledDataSource:池化 DataSource

PooledDataSource 仍然实现标准的 DataSource

text 复制代码
JdbcTemplate
    |
    v
javax.sql.DataSource
    |
    +-- PooledDataSource
            |
            +-- PooledConnection
                    |
                    +-- 真实数据库 Connection

初始化连接池:

java 复制代码
public void initPool() throws SQLException {
    synchronized (this.poolMonitor) {
        if (this.initialized) {
            return;
        }

        assertConfigured();
        loadDriver();
        this.connections = new ArrayList<>(this.initialSize);
        for (int i = 0; i < this.initialSize; i++) {
            PooledConnection pooledConnection =
                    new PooledConnection(
                            createPhysicalConnection(this.username, this.password),
                            false);
            pooledConnection.setCloseCallback(this::notifyConnectionReturned);
            this.connections.add(pooledConnection);
        }
        this.initialized = true;
    }
}

借连接时,如果池里没有空闲连接,就等待一小段时间;超过 maxWaitMillis 还拿不到,就抛出异常:

java 复制代码
@Override
public Connection getConnection() throws SQLException {
    initPool();

    long deadline = System.currentTimeMillis() + this.maxWaitMillis;
    synchronized (this.poolMonitor) {
        while (true) {
            PooledConnection pooledConnection = getAvailableConnection();
            if (pooledConnection != null) {
                return pooledConnection;
            }

            long waitMillis = deadline - System.currentTimeMillis();
            if (waitMillis <= 0) {
                throw new SQLException("No available connection in pool within "
                        + this.maxWaitMillis + " ms");
            }

            try {
                this.poolMonitor.wait(Math.min(waitMillis, 30));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new SQLException(
                        "Interrupted while waiting for a pooled connection", e);
            }
        }
    }
}

查找空闲连接:

java 复制代码
private PooledConnection getAvailableConnection() throws SQLException {
    for (PooledConnection pooledConnection : this.connections) {
        if (!pooledConnection.isActive()) {
            Connection physicalConnection = pooledConnection.getConnection();
            if (physicalConnection == null || physicalConnection.isClosed()) {
                pooledConnection.setConnection(
                        createPhysicalConnection(this.username, this.password));
            }
            pooledConnection.setActive(true);
            return pooledConnection;
        }
    }
    return null;
}

归还连接时会唤醒等待线程:

java 复制代码
private void notifyConnectionReturned() {
    synchronized (this.poolMonitor) {
        this.poolMonitor.notifyAll();
    }
}

关闭容器时,连接池才真正关闭物理连接:

java 复制代码
@Override
public void close() throws SQLException {
    synchronized (this.poolMonitor) {
        if (this.connections != null) {
            for (PooledConnection connection : this.connections) {
                connection.closePhysicalConnection();
            }
            this.connections.clear();
            this.connections = null;
            this.initialized = false;
            this.poolMonitor.notifyAll();
        }
    }
}

项目里的完整实现还处理了多个关闭异常的收集,这里只保留主线。

六、交给 IoC 容器装配

applicationContext.xml 中把原来的数据源换成 PooledDataSource

xml 复制代码
<bean id="dataSource" class="com.chenhai.jdbc.pool.PooledDataSource">
    <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:}?useUnicode=true&amp;characterEncoding=UTF-8&amp;serverTimezone=Asia/Shanghai&amp;useSSL=false&amp;allowPublicKeyRetrieval=true"/>
    <property type="String" name="username" value="${MYSQL_USER:root}"/>
    <property type="String" name="password" value="${MYSQL_PASSWORD:}"/>
    <property type="int"
              name="initialSize"
              value="${MYSQL_POOL_INITIAL_SIZE:3}"/>
</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"/>

因为 PooledDataSource 实现了 DataSource,所以 JdbcTemplate 只需要这一行:

java 复制代码
Connection connection = dataSource.getConnection();

它不需要知道底层是普通连接,还是连接池里的逻辑连接。

容器关闭时,AbstractApplicationContext.close() 会销毁单例 Bean。如果单例实现了 AutoCloseable,就调用它的 close()

java 复制代码
protected void destroySingletons() {
    ConfigurableListableBeanFactory beanFactory = getBeanFactory();
    String[] singletonNames = beanFactory.getSingletonNames();
    for (int i = singletonNames.length - 1; i >= 0; i--) {
        Object singleton = beanFactory.getSingleton(singletonNames[i]);
        if (singleton instanceof AutoCloseable) {
            try {
                ((AutoCloseable) singleton).close();
            } catch (Exception e) {
                throw new IllegalStateException(
                        "Destroy bean failed: " + singletonNames[i], e);
            }
        }
    }
}

这一步很重要。连接池创建的物理连接不能只借不还,也不能在 JVM 退出前一直没人管。既然连接池是容器创建的,销毁也应该由容器负责。

七、完整调用链

userService.getUsers(1) 为例,调用链现在是:

text 复制代码
UserService.getUsers(1)
        ↓
准备 SQL、参数、RowMapper
        ↓
JdbcTemplate.query(sql, args, RowMapper)
        ↓
new RowMapperResultSetExtractor(rowMapper)
        ↓
JdbcTemplate.query(sql, args, ResultSetExtractor)
        ↓
JdbcTemplate.execute(sql, args, PreparedStatementCallback)
        ↓
PooledDataSource.getConnection()
        ↓
借出 PooledConnection
        ↓
ArgumentPreparedStatementSetter.setValues(...)
        ↓
PreparedStatement.executeQuery()
        ↓
RowMapperResultSetExtractor.extractData(...)
        ↓
循环调用 RowMapper.mapRow(...)
        ↓
try-with-resources 调用 connection.close()
        ↓
PooledConnection.close() 把连接归还给池

到这里,几个角色的职责就比较清楚了:

组件 职责
UserService 提供 SQL、参数和行映射逻辑
JdbcTemplate 管理 JDBC 固定流程和异常边界
ArgumentPreparedStatementSetter 绑定 SQL 参数
RowMapper 映射当前行
ResultSetExtractor 提取整个结果集
PooledDataSource 管理连接池
PooledConnection 包装物理连接,让 close() 变成归还连接

八、当前实现边界

这一章的代码是教学实现,重点是看清抽象过程。它已经能说明核心思想,但还不是生产级 JDBC 框架。

当前还没有包含:

  • 事务传播;
  • 连接泄漏检测;
  • 空闲连接保活;
  • 最大连接数扩容;
  • 连接有效性定期检查;
  • Spring JDBC 那样细分的异常体系;
  • queryForObject(...) 的严格单行结果校验。

对应的测试主要验证:

  1. JdbcTemplate 能通过 ArgumentPreparedStatementSetter 绑定参数;
  2. RowMapperResultSetExtractor 能把多行结果转成列表;
  3. PooledConnection.close() 会把连接归还给池;
  4. 池耗尽时,PooledDataSource 会等待并在超时后抛出异常;
  5. XML 能把 PooledDataSource 注入到 JdbcTemplate

这一节的核心思路可以收成一句话:

JdbcTemplate 继续负责总流程,但参数绑定、结果集提取和连接复用,都交给更专门的部件完成。