Skip to content

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

你好,我是郭屹,今天我们继续手写MiniSpring。

上节课,我们从JDBC这些套路性的程序流程中抽取出了一个通用模板。然后进行了拆解,将SQL语句当作参数传入,而SQL语句执行之后的结果处理逻辑也作为一个匿名类传入,又抽取出了数据源的概念。下面我们接着上节课的思路,继续拆解JDBC程序。

我们现在观察应用程序怎么使用的JdbcTemplate,看这些代码,还是会发现几个问题。

  1. SQL语句参数的传入还是一个个写进去的,没有抽取出一个独立的部件进行统一处理。
  2. 返回的记录是单行的,不支持多行的数据集,所以能对上层应用程序提供的API非常有限。
  3. 另外每次执行SQL语句都会建立连接、关闭连接,性能会受到很大影响。

这些问题,我们都需要在这节课上一个个解决。

参数传入

先看SQL语句参数的传入问题,我们注意到现在往PreparedStatement中传入参数是这样实现的。

    for (int i = 0; i < args.length; i++) {
        Object arg = args[i];
        if (arg instanceof String) {
            pstmt.setString(i+1, (String)arg);
        }
        else if (arg instanceof Integer) {
            pstmt.setInt(i+1, (int)arg);
        }
        else if (arg instanceof java.util.Date) {
            pstmt.setDate(i+1, new java.sql.Date(((java.util.Date)arg).getTime()));
        }
    }

简单地说,这些参数都是一个个手工传入进去的。但我们想让参数传入的过程自动化一点,所以现在我们来修改一下,把JDBC里传参数的代码进行包装,用一个专门的部件专门做这件事情,于是我们引入 ArgumentPreparedStatementSetter,通过里面的setValues()方法把参数传进PreparedStatement。

package com.minis.jdbc.core;

import java.sql.PreparedStatement;
import java.sql.SQLException;

public class ArgumentPreparedStatementSetter {
    private final Object[] args; //参数数组

    public ArgumentPreparedStatementSetter(Object[] args) {
        this.args = args;
    }
    //设置SQL参数
    public void setValues(PreparedStatement pstmt) throws SQLException {
        if (this.args != null) {
            for (int i = 0; i < this.args.length; i++) {
                Object arg = this.args[i];
                doSetValue(pstmt, i + 1, arg);
            }
        }
    }
    //对某个参数,设置参数值
    protected void doSetValue(PreparedStatement pstmt, int parameterPosition, Object argValue) throws SQLException {
        Object arg = argValue;
        //判断参数类型,调用相应的JDBC set方法
        if (arg instanceof String) {
            pstmt.setString(parameterPosition, (String)arg);
        }
        else if (arg instanceof Integer) {
            pstmt.setInt(parameterPosition, (int)arg);
        }
        else if (arg instanceof java.util.Date) {
            pstmt.setDate(parameterPosition, new java.sql.Date(((java.util.Date)arg).getTime()));
        }
    }
}

从代码中可以看到,核心仍然是JDBC的set方法,但是包装成了一个独立部件。现在的示例程序只是针对了String、Int和Date三种数据类型,更多的数据类型我们留到后面再扩展。

有了这个专门负责参数传入的setter之后,query()就修改成这个样子。

    public Object query(String sql, Object[] args, PreparedStatementCallback pstmtcallback) {
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            //通过data source拿数据库连接
            con = dataSource.getConnection();

            pstmt = con.prepareStatement(sql);
            //通过argumentSetter统一设置参数值
            ArgumentPreparedStatementSetter argumentSetter = new ArgumentPreparedStatementSetter(args);
            argumentSetter.setValues(pstmt);

            return pstmtcallback.doInPreparedStatement(pstmt);
        }
        catch (Exception e) {
                e.printStackTrace();
        }
        finally {
            try {
                pstmt.close();
                con.close();
            } catch (Exception e) {
            }
        }
        return null;
    }

我们可以看到,代码简化了很多,手工写的一大堆设置参数的代码不见了,这就体现了专门的部件做专门的事情的优点。

对返回结果的处理

JDBC来执行SQL语句,说起来很简单,就三步,一准备参数,二执行语句,三处理返回结果。准备参数和执行语句这两步我们上面都已经抽取了。接下来我们再优化一下处理返回值的代码,看看能不能提供更多便捷的方法。

我们先看一下现在是怎么处理的,程序体现在pstmtcallback.doInPreparedStatement(pstmt)这个方法里,这是一个callback类,由用户程序自己给定,一般会这么做。

    return (User)jdbcTemplate.query(sql, new Object[]{new Integer(userid)},
        (pstmt)->{
            ResultSet rs = pstmt.executeQuery();
            User rtnUser = null;
            if (rs.next()) {
                rtnUser = new User();
                rtnUser.setId(userid);
                rtnUser.setName(rs.getString("name"));
                rtnUser.setBirthday(new java.util.Date(rs.getDate("birthday").getTime()));
            } else {
            }
            return rtnUser;
        }
    );

这个本身没有什么问题,这部分逻辑实际上已经剥离出去了。只不过,它限定了用户只能用这么一种方式进行。有时候很不便利,我们还应该考虑给用户程序提供多种方式。比如说,我们想返回的不是一个对象(对应数据库中一条记录),而是对象列表(对应数据库中多条记录)。这种场景很常见,需要我们再单独提供一个便利的工具。

所以我们设计一个接口RowMapper,把JDBC返回的ResultSet里的某一行数据映射成一个对象。

package com.minis.jdbc.core;

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

public interface RowMapper<T> {
    T mapRow(ResultSet rs, int rowNum) throws SQLException;
}

再提供一个接口ResultSetExtractor,把JDBC返回的ResultSet数据集映射为一个集合对象。

package com.minis.jdbc.core;

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

public interface ResultSetExtractor<T> {
    T extractData(ResultSet rs) throws SQLException;
}

利用上面的两个接口,我们来实现一个RowMapperResultSetExtractor。

package com.minis.jdbc.core;

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

public class RowMapperResultSetExtractor<T> implements ResultSetExtractor<List<T>> {
    private final RowMapper<T> rowMapper;

    public RowMapperResultSetExtractor(RowMapper<T> rowMapper) {
        this.rowMapper = rowMapper;
    }

    @Override
    public List<T> extractData(ResultSet rs) throws SQLException {
        List<T> results = new ArrayList<>();
        int rowNum = 0;
        //对结果集,循环调用mapRow进行数据记录映射
        while (rs.next()) {
            results.add(this.rowMapper.mapRow(rs, rowNum++));
        }
        return results;
    }
}

这样,SQL语句返回的数据集就自动映射成对象列表了。我们看到,实际的数据映射工作其实不是我们实现的,而是由RowMapper实现的,这个RowMapper既是作为一个参数又是作为一个用户程序传进去的。这很合理,因为确实只有用户程序自己知道自己的数据要如何映射。

好,有了这个工具,我们可以提供一个新的query()方法来返回SQL语句的结果集,代码如下:

    public <T> List<T> query(String sql, Object[] args, RowMapper<T> rowMapper) {
        RowMapperResultSetExtractor<T> resultExtractor = new RowMapperResultSetExtractor<>(rowMapper);
        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            //建立数据库连接
            con = dataSource.getConnection();

            //准备SQL命令语句
            pstmt = con.prepareStatement(sql);
            //设置参数
            ArgumentPreparedStatementSetter argumentSetter = new ArgumentPreparedStatementSetter(args);
            argumentSetter.setValues(pstmt);
            //执行语句
            rs = pstmt.executeQuery();

            //数据库结果集映射为对象列表,返回
            return resultExtractor.extractData(rs);
        }
        catch (Exception e) {
                e.printStackTrace();
        }
        finally {
            try {
                pstmt.close();
                con.close();
            } catch (Exception e) {
            }
        }
        return null;
    }

那么上层应用程序的service层要改成这样:

    public List<User> getUsers(int userid) {
        final String sql = "select id, name,birthday from users where id>?";
        return (List<User>)jdbcTemplate.query(sql, new Object[]{new Integer(userid)},
                new RowMapper<User>(){
                    public User mapRow(ResultSet rs, int i) throws SQLException {
                        User rtnUser = new User();
                        rtnUser.setId(rs.getInt("id"));
                        rtnUser.setName(rs.getString("name"));
                        rtnUser.setBirthday(new java.util.Date(rs.getDate("birthday").getTime()));

                        return rtnUser;
                    }
                }
        );
    }

service程序里面执行SQL语句,直接按照数据记录的字段的mapping关系,返回一个对象列表。这样,到此为止,MiniSpring的JdbcTemplate就可以提供3种query()方法了。

  1. public Object query(StatementCallback stmtcallback) {}
  2. public Object query(String sql, Object[] args, PreparedStatementCallback pstmtcallback) {}
  3. public List query(String sql, Object[] args, RowMapper rowMapper){}

实际上我们还可以提供更多的工具,你可以举一反三思考一下应该怎么做,这里我就不多说了。

数据库连接池

到现在这一步,我们的MiniSpring仍然是在执行SQL语句的时候,去新建数据库连接,使用完之后就释放掉了。我们知道,数据库连接的建立和释放,是很费资源和时间的。所以这个方案不是最优的,那怎样才能解决这个问题呢?有一个方案可以试一试,那就是 池化技术。提前在一个池子里预制多个数据库连接,在应用程序来访问的时候,就给它一个,用完之后再收回到池子中,整个过程中数据库连接一直保持不关闭,这样就大大提升了性能。

所以我们需要改造一下原有的数据库连接,不把它真正关闭,而是设置一个可用不可用的标志。我们用一个新的类,叫PooledConnection,来实现Connetion接口,里面包含了一个普通的Connection,然后用一个标志Active表示是否可用,并且永不关闭。

package com.minis.jdbc.pool;
public class PooledConnection implements Connection{
    private Connection connection;
    private boolean active;

    public PooledConnection() {
    }
    public PooledConnection(Connection connection, boolean active) {
        this.connection = connection;
        this.active = active;
    }

    public Connection getConnection() {
        return connection;
    }
    public void setConnection(Connection connection) {
        this.connection = connection;
    }
    public boolean isActive() {
        return active;
    }
    public void setActive(boolean active) {
        this.active = active;
    }
    public void close() throws SQLException {
        this.active = false;
    }
    @Override
    public PreparedStatement prepareStatement(String sql) throws SQLException {
        return this.connection.prepareStatement(sql);
    }
}

实际代码很长,因为要实现JDBC Connection接口里所有的方法,你可以参考上面的示例代码,别的可以都留空。

最主要的,我们要注意close()方法,它其实不会关闭连接,只是把这个标志设置为false。

基于上面的PooledConnection,我们把原有的DataSource改成PooledDataSource。首先在初始化的时候,就激活所有的数据库连接。

package com.minis.jdbc.pool;

public class PooledDataSource implements DataSource{
    private List<PooledConnection> connections = null;
    private String driverClassName;
    private String url;
    private String username;
    private String password;
    private int initialSize = 2;
    private Properties connectionProperties;

    private void initPool() {
        this.connections = new ArrayList<>(initialSize);
        for(int i = 0; i < initialSize; i++){
            Connection connect = DriverManager.getConnection(url, username, password);
            PooledConnection pooledConnection = new PooledConnection(connect, false);
            this.connections.add(pooledConnection);
        }
    }
}

获取数据库连接的代码如下:

    PooledConnection pooledConnection= getAvailableConnection();
    while(pooledConnection == null){
        pooledConnection = getAvailableConnection();
        if(pooledConnection == null){
            try {
                TimeUnit.MILLISECONDS.sleep(30);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    return pooledConnection;

可以看出,我们的策略是死等这一个有效的连接。而获取有效连接的代码如下:

    private PooledConnection getAvailableConnection() throws SQLException{
        for(PooledConnection pooledConnection : this.connections){
            if (!pooledConnection.isActive()){
                pooledConnection.setActive(true);
                return pooledConnection;
            }
        }

        return null;
    }

通过代码可以知道,其实它就是拿一个空闲标志的数据库连接来返回。逻辑上这样是可以的,但是,这段代码就会有一个并发问题,多线程的时候不好用,需要改造一下才能适应多线程环境。我们注意到这个池子用的是一个简单的ArrayList,这个默认是不同步的,我们需要手工来做同步,比如使用Collections.synchronizedList(),或者用两个LinkedBlockingQueue,一个用于active连接,一个用于inactive连接。

同样,对DataSource里数据库的相关信息,可以通过配置来注入的。

<bean id="dataSource" class="com.minis.jdbc.pool.PooledDataSource">
    <property name="url" value="jdbc:sqlserver://localhost:1433;databasename=DEMO"/>
    <property name="driverClassName" value="com.microsoft.sqlserver.jdbc.SQLServerDriver"/>
    <property name="username" value="sa"/>
    <property name="password" value="Sql2016"/>
    <property type="int" name="initialSize" value="3"/>
</bean>

整个程序的结构实际上没有什么改动,只是将DataSource的实现变成了支持连接池的实现。从这里也可以看出,独立抽取部件、解耦这些手段给程序结构带来了极大的灵活性。

小结

我们这节课,在已有的JdbcTemplate基础之上,仍然按照专门的事情交给专门的部件来做的思路,一步步拆解。

我们把SQL语句参数的处理独立成一个ArgumentPreparedStatementSetter,由它来负责参数的传入。之后对返回结果,我们提供了RowMapper和RowMapperResultSetExtractor,将数据库记录集转换成一个对象的列表,便利了上层应用程序。最后考虑到性能,我们还引入了一个简单的数据库连接池。在这一步步地拆解过程中,JdbcTemplate这个工具越来越完整、便利了。

完整源代码参见 https://github.com/YaleGuo/minis

课后题

学完这节课的内容,我也给你留一道思考题。你想一想我们应该怎么改造数据库连接池,保证多线程安全?欢迎你在留言区与我交流讨论,也欢迎你把这节课分享给需要的朋友。我们下节课见!