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

大家好,我是郭屹,今天我们将继续探讨如何手写MiniSpring框架。 在上一堂课中,我们从JDBC的常规程序流程中抽象出了一个通用模板,并对其进行了拆解。我们把SQL语句作为参数传递,并将SQL语句执行后的结果处理逻辑作为一个匿名类传递。此外,我们还引入了数据源的概念。现在,我们将继续沿着上节课的思路,进一步拆解JDBC程序。

问题审视

在观察应用程序如何使用JdbcTemplate时,我们发现了几个问题:

  1. SQL语句参数的传递仍然是逐个手动完成的,没有抽象出一个独立的部件来进行统一处理。
  2. 返回的记录仅支持单行,不支持多行数据集,这限制了对上层应用程序提供的API。
  3. 每次执行SQL语句时都需要建立和关闭连接,这严重影响了性能。 本节课,我们将逐一解决这些问题。

参数传递问题

首先,我们来看SQL语句参数的传递问题。我们注意到,向PreparedStatement中传递参数的实现方式如下: [此处应有代码示例] 我们的目标是将参数传递过程抽象化,以简化代码并提高效率。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	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。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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()方法变得更加简洁,而且为将来增加对更多数据类型的支持奠定了良好的基础。 随着专门负责参数设置的setter组件就位,query()方法也相应地进行了调整。现在query()方法将更加专注于执行查询逻辑本身,而具体的参数绑定工作则交由新的setter组件来完成。这样分离关注点的做法有助于提高代码的可读性和可测试性。 例如,当需要执行一条SQL语句并传入参数时,我们不再直接调用JDBC的PreparedStatementsetXxx方法,而是通过调用封装好的setter组件中的对应方法来进行。这使得主业务逻辑与底层细节解耦,同时保证了对于不同数据类型的正确处理。 接下来的工作将是逐步扩展这个setter组件,以支持更多的数据类型,如浮点数(Float, Double)、大整数(BigInteger)、时间戳(Timestamp)等。这样做不仅可以丰富我们对不同类型数据的操作能力,还能进一步提升整个系统的灵活性和适应性。 总之,通过对JDBC set方法的封装,我们实现了更好的抽象层次以及更清晰的责任划分。这种方式让应用程序结构更为合理,也为未来的开发留下了足够的空间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
	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类,由用户程序自己给定,一般会这么做。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	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里的某一行数据映射成一个对象。

1
2
3
4
5
6
7
8
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数据集映射为一个集合对象。

1
2
3
4
5
6
7
8
package com.minis.jdbc.core;

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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就是这样一个角色。它不仅作为一个参数传递给查询方法,还充当了用户程序的一部分,因为只有用户自己才完全了解自己的数据如何映射到对象上。 基于这个原理,我们可以设计一个新的query()方法,该方法能够返回SQL语句执行后的结果集。以下是该方法的实现步骤和逻辑:

  1. 定义RowMapper接口:这个接口负责定义如何将数据库中的一行记录映射到特定的对象上。

  2. 实现query()方法:这个方法将接受SQL语句和一个RowMapper实例作为参数,执行查询并将结果集映射为对象列表。

  3. 执行查询:使用数据库连接执行传入的SQL语句。

  4. 映射结果:对于查询返回的每一行数据,使用RowMapper实例将数据映射为对象。

  5. 返回对象列表:将映射后的对象列表作为方法的返回值。 这种方法的好处在于,它将数据映射的逻辑从查询逻辑中分离出来,使得代码更加模块化和可重用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
	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层要改成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	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;
					}
				}
		);
	}

MiniSpring的JdbcTemplate增强

MiniSpring的JdbcTemplate目前已经提供了三种query()方法,用于执行SQL语句并返回结果。以下是这三种方法的详细说明:

  1. public Object query(StatementCallback stmtcallback):使用此方法时,JdbcTemplate将为每个查询创建一个新的Statement,并在回调中执行。
  2. public Object query(String sql, Object[] args, PreparedStatementCallback pstmtcallback):此方法允许预编译SQL语句,并通过回调处理查询结果。
  3. public List query(String sql, Object[] args, RowMapper rowMapper):通过RowMapper将查询结果映射为对象列表。

扩展工具

除了上述方法,我们还可以考虑提供更多的工具来增强JdbcTemplate的功能。例如,可以增加对事务的支持,或者提供更灵活的查询结果处理方式。

数据库连接池

目前,MiniSpring在执行SQL语句时,每次都会新建数据库连接并在使用后立即释放,这在资源和时间上都是不经济的。为了解决这个问题,我们可以采用池化技术,预先在池中创建多个数据库连接,应用程序需要时直接使用,使用完毕后再归还到池中,这样可以显著提高性能。

PooledConnection类设计

为了实现数据库连接池,我们需要设计一个新的类PooledConnection,该类实现Connection接口,并包含以下元素:

  • 一个普通的Connection对象,用于实际的数据库连接。

  • 一个Active标志,用于表示连接是否可用。

  • 连接永不关闭,而是通过Active标志来管理其生命周期。 通过这种方式,我们可以有效地管理数据库连接,减少资源消耗,提高应用程序的性能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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。首先在初始化的时候,就激活所有的数据库连接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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);
		}
	}
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	PooledConnection pooledConnection= getAvailableConnection();
	while(pooledConnection == null){
		pooledConnection = getAvailableConnection();
		if(pooledConnection == null){
			try {
				TimeUnit.MILLISECONDS.sleep(30);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
    return pooledConnection;

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	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里数据库的相关信息,可以通过配置来注入的。

1
2
3
4
5
6
7
<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>

程序结构的灵活性与解耦

在我们最近的学习中,我们基于JdbcTemplate进行了进一步的功能扩展。尽管程序的整体结构没有发生重大变化,但通过引入支持连接池的数据源实现,我们显著提升了程序处理数据库连接时的效率和可维护性。这种做法展示了独立组件设计和模块间解耦的重要性,为系统带来了极大的灵活性。

关键改进点

  • 参数处理器:将SQL语句参数的设置任务交给了ArgumentPreparedStatementSetter类来完成,使得这部分代码更加清晰且易于管理。

  • 结果映射器:为了简化从数据库记录集到对象列表转换的过程,我们开发了RowMapper接口及其配套的RowMapperResultSetExtractor,这不仅提高了代码复用率也增强了上层应用的操作便捷性。

  • 连接池技术:出于性能考虑,加入了简单的数据库连接池机制,以优化资源利用并提高应用程序响应速度。 通过这些逐步细化的设计步骤,JdbcTemplate逐渐演变成一个更为强大且用户友好的工具。

完整源码

若希望深入研究本次课程所涉及的所有代码细节,请访问此处获取完整项目。

课后思考题

现在,请你考虑这样一个问题:如何改造现有的数据库连接池方案,使其能够更好地适应多线程环境下的安全需求?欢迎在评论区分享你的想法,并邀请更多朋友加入讨论。aaaaaaa 期待下节课与大家见面!