mini-spring-course

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

课后题

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