Spring 的 JdbcTemplate 为我们操作数据库提供非常大的便利,不需要显式的管理资源和处理异常。在我们进入到了 Java 8 后,JdbcTemplate 方法中的回调函数可以用 Lambda 表达式进行简化,而本文要说的正是这种 Lambda 简化容易给我们带来的一个 Bug, 这是我在一个实际项目中写的单元测试发现的。
下面就是我们的一个样板代码,在我们的 UserRespository 中有一个方法 findAll() 用于获得所有用户:
| 1 2 3 4 5 6 7 8 9 | public List<User> findAll() {     List<User> users = new ArrayList<>();     jdbcTemplate.query("select id, name from user", rs -> {         while (rs.next()) {             users.add(new User(rs.getInt("id"), rs.getString("name")));         }     });     return users; } | 
初看上面的代码,好像也没问题啊,调用 jdbcTemplate.query(sql, callback) 方法执行 SQL 语句,接着在回调函数中拿到 ResultSet 循环获得每一行结果啊。
那么我们用事实来验证,下面是相应的测试代码
| 1 2 3 4 5 6 7 8 9 10 | @Test @Sql(statements = {     "delete from user",     "INSERT INTO user(id, name) VALUES(1, 'user1'), (2, 'user2'), (3,'user3'), (4, 'user4'), (5, 'user5')" }) public void findAllShouldFetchAllUsers() {     List<User> allUsers = userRepository.findAll();     allUsers.forEach(System.out::println);     assertEquals(5, allUsers.size()); } | 
用 @Sql 往数据库中只插入 5 条记录,可是上面的断言失败了
java.lang.AssertionError:
Expected :5
Actual :4
findAll()  返回的是 4 条记录,而不是我们所期望的 5 条记录,那么还有一条记录跑哪去了。上面的 allUser.forEach(System.out::println)  打印出来的结果是:
User{id=2, name='user2'}
User{id=3, name='user3'}
User{id=4, name='user4'}
User{id=5, name='user5'}
是的,第一条记录不见了,如果我们反复针对数据库表中不同的记录数进行测试的的话,丢失的记录总是第一条。分析总是丢失第一条记录的原因肯定是有人帮我们做了一次 rs.next()  把光标跳了一下。
这是为何呢?这就是我要说的 JdbcTemplate 被 Java 8 的 Lambda 表达式带沟里去了,因为 Lambda,让我们忽略了方法原型是什么,Lambda 相对应的 @FunctionalInterface  是什么,同时 IDE 也是帮凶。因为当我们在 IDE 中写到
jdbcTemplate.query("select id, name from user", rs -> {
后,很容易仗着先前用原生 JDBC 操作 ResultSet 的惯性立即就会对 rs 变量用 whilc (rs.next) {...} 进行遍历,于是问题就发生了。
如果我们回归到从前,还是用匿名类的方式来写回调函数的时候,findAll() 相应的不正确的代码就是
| 1 2 3 4 5 6 7 8 9 10 11 12 | public List<User> findAll() {     List<User> users = new ArrayList<>();     jdbcTemplate.query("select id, name from user", new RowCallbackHandler() {         @Override         public void processRow(ResultSet rs) throws SQLException {             while (rs.next()) {                 users.add(new User(rs.getInt("id"), rs.getString("name")));             }         }     });     return users; } | 
现在我们明明白白的能看到回调函数的类型是 RowCallbackHandler, 如类名所示,它就是处理 ResultSet 的当前行, 有人在帮我们遍历结果集,所以我们再次对 ResultSet 就跳过了第一行记录。
在应用 Java 8 之前的 JDK, 我们出现上面错误的概率应该很小的吧,会写成如下正确的代码
| 1 2 3 4 5 6 7 8 9 10 | public List<User> findAll() {     List<User> users = new ArrayList<>();     jdbcTemplate.query("select id, name from user", new RowCallbackHandler() {         @Override         public void processRow(ResultSet rs) throws SQLException {            users.add(new User(rs.getInt("id"), rs.getString("name")));         }     });     return users; } | 
因此再回到我们 Java 8 用 Lambda 简化后的版本就是
| 1 2 3 4 5 6 7 | public List<User> findAll() {     List<User> users = new ArrayList<>();     jdbcTemplate.query("select id, name from user", rs -> {         users.add(new User(rs.getInt("id"), rs.getString("name")));     });     return users; } | 
这个才是正确的代码,相比于文中最开始出现的错误代码,我们做了一件吃力不讨好的事情,代码行多了反而引入了一个 Bug。
这真是被 Java 8 的 Lambda 和 IDE 惯坏了,当我们在享受 Lambda 给我们带来便利的同时,却忘记了自己是谁,方法原型是什么,以及Lambda 所代表的功能性接口是什么。
针对上面的 findAll()  方法的的意图,其实我们更应该调用
<T> List<T> query(String sql, RowMapper<T> rowMapper) throws DataAccessException;
而不是现在的
void query(String sql, RowCallbackHandler rch) throws DataAccessException;
对上面的方法再进一步简化就是
| 1 2 3 4 | public List<User> findAll() {     return jdbcTemplate.query("select id, name from user",         (rs, index) -> new User(rs.getInt("id"), rs.getString("name"))); } | 
为何我这么衷情于 JdbcTemplate 的各个 query(...) 的重载方法呢,而不是直接调用 queryForList(...), 各种变体呢?因为有时候需要作流式处理,而是一下把所有结果全加载到内存中。当然这里的 findAll() 完全可以用 queryForList(...)  来简化
| 1 2 3 | public List<Map<String, Object>> findAll() {     return jdbcTemplate.queryForList("select id, name from user", new BeanPropertyRowMapper<>(User.class)); } | 
话说到现在,我们还是有必要从 JdbcTemplate 的原代码来理解 query(String sql, RowCallbackHandler rch) 的实现原理。下面的代码来自于 JdbcTemplate 类
| 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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | @Override public <T> T query(final String sql, final ResultSetExtractor<T> rse) throws DataAccessException {     Assert.notNull(sql, "SQL must not be null");     Assert.notNull(rse, "ResultSetExtractor must not be null");     if (logger.isDebugEnabled()) {         logger.debug("Executing SQL query [" + sql + "]");     }     class QueryStatementCallback implements StatementCallback<T>, SqlProvider {         @Override         public T doInStatement(Statement stmt) throws SQLException {             ResultSet rs = null;             try {                 rs = stmt.executeQuery(sql);                 ResultSet rsToUse = rs;                 if (nativeJdbcExtractor != null) {                     rsToUse = nativeJdbcExtractor.getNativeResultSet(rs);                 }                 return rse.extractData(rsToUse);             }             finally {                 JdbcUtils.closeResultSet(rs);             }         }         @Override         public String getSql() {             return sql;         }     }     return execute(new QueryStatementCallback()); } @Override public void query(String sql, RowCallbackHandler rch) throws DataAccessException {     query(sql, new RowCallbackHandlerResultSetExtractor(rch)); } private static class RowCallbackHandlerResultSetExtractor implements ResultSetExtractor<Object> {     private final RowCallbackHandler rch;     public RowCallbackHandlerResultSetExtractor(RowCallbackHandler rch) {         this.rch = rch;     }     @Override     public Object extractData(ResultSet rs) throws SQLException {         while (rs.next()) {             this.rch.processRow(rs);         }         return null;     } } | 
关键是类 RowCallbackHandlerResultSetExtractor, 它在遍历结果集,针对每一行调用我们传入的回调函数,所以它至少有一次机会作 rs.next(), 如果我们在 Lambda  也作一次 rs.next()  就成功的跳过了第一条记录。
这里还有一个要非常小心的地方,如果调用的是
<T> T query(String sql, ResultSetExtractor<T> rse)
而不是
void query(String sql, RowCallbackHandler rch)
的话,是可以在 Lambda 中进行自主 while(rs.next())  的,即下面的代码是下确的
| 1 2 3 4 5 6 7 8 9 | public List<User> findAll() {     return jdbcTemplate.query("select * from user", rs -> {         List<User> users = new ArrayList<>();         while(rs.next()) {             users.add(new User(rs.getInt("id"), rs.getString("name")));         }         return users;     }); } | 
Lambda 的写法上与第一段代码毫无区别,唯一的不同是这个 query 方法有返回值。也就是说
- 有返回值的 JdbcTemplate.query(sql, rs -> {....}) 要自己遍历结果集
- 无反回值的  JdbcTemplate.query(sql, rs -> {....}) 不可自己遍历结果集,否则会丢失第一条记录,也就是在 Lambda 内部最后写上一句 return null就行为大变了
Java 中是不能仅以返回值的不同来重载方法,但是转换为 Lambda 表达式制造出来的假象就是根据返回值的不同而调用了不同的方法。
何时可以 while(rs.next())  何时不可以,真是极具隐蔽性,而且出问题了还不明显,真是一个事故多发地,一不小心就会踩上地雷。
本文链接 https://yanbin.blog/jdbctemplate-java-8-lambda-trick/, 来自 隔叶黄莺 Yanbin Blog
[版权声明]  本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
近一段时间咋老是弄这个java8了啊?很期待你的spark相关系列博文的发布哦。
java8美则美矣,但是其中的很多功能,其他第三方类库如guava都有实现。而且直接使用java8进行开发还有个问题:很多老的应用或服务器所依赖的还是jdk1.6,java8开发的代码,很可能无法部署到旧服务器或与老的应用相互兼容。