14 | 增强模板:如何抽取专门的部件完成专门的任务?
大家好,我是郭屹,今天我们将继续探讨如何手写MiniSpring框架。
在上一堂课中,我们从JDBC的常规程序流程中抽象出了一个通用模板,并对其进行了拆解。我们把SQL语句作为参数传递,并将SQL语句执行后的结果处理逻辑作为一个匿名类传递。此外,我们还引入了数据源的概念。现在,我们将继续沿着上节课的思路,进一步拆解JDBC程序。
问题审视
在观察应用程序如何使用JdbcTemplate时,我们发现了几个问题:
- SQL语句参数的传递仍然是逐个手动完成的,没有抽象出一个独立的部件来进行统一处理。
- 返回的记录仅支持单行,不支持多行数据集,这限制了对上层应用程序提供的API。
- 每次执行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的PreparedStatement
的setXxx
方法,而是通过调用封装好的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语句执行后的结果集。以下是该方法的实现步骤和逻辑:
-
定义RowMapper
接口:这个接口负责定义如何将数据库中的一行记录映射到特定的对象上。
-
实现query()
方法:这个方法将接受SQL语句和一个RowMapper
实例作为参数,执行查询并将结果集映射为对象列表。
-
执行查询:使用数据库连接执行传入的SQL语句。
-
映射结果:对于查询返回的每一行数据,使用RowMapper
实例将数据映射为对象。
-
返回对象列表:将映射后的对象列表作为方法的返回值。
这种方法的好处在于,它将数据映射的逻辑从查询逻辑中分离出来,使得代码更加模块化和可重用。
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语句并返回结果。以下是这三种方法的详细说明:
public Object query(StatementCallback stmtcallback)
:使用此方法时,JdbcTemplate将为每个查询创建一个新的Statement,并在回调中执行。
public Object query(String sql, Object[] args, PreparedStatementCallback pstmtcallback)
:此方法允许预编译SQL语句,并通过回调处理查询结果。
public List query(String sql, Object[] args, RowMapper rowMapper)
:通过RowMapper将查询结果映射为对象列表。
扩展工具
除了上述方法,我们还可以考虑提供更多的工具来增强JdbcTemplate的功能。例如,可以增加对事务的支持,或者提供更灵活的查询结果处理方式。
数据库连接池
目前,MiniSpring在执行SQL语句时,每次都会新建数据库连接并在使用后立即释放,这在资源和时间上都是不经济的。为了解决这个问题,我们可以采用池化技术,预先在池中创建多个数据库连接,应用程序需要时直接使用,使用完毕后再归还到池中,这样可以显著提高性能。
PooledConnection类设计
为了实现数据库连接池,我们需要设计一个新的类PooledConnection
,该类实现Connection
接口,并包含以下元素:
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
期待下节课与大家见面!