第06篇 · Spring JDBC:模板模式消灭重复代码的艺术
如果你完整跟下了前几篇,亲手写过原生 JDBC,也配置过连接池,那你现在应该已经能顺畅地操作数据库了。
但你可能也发现了一个问题:连接池只解决了“创建连接”的代价,但每次操作数据库,该写的 try-catch-finally 一行没少,该手动映射的 rs.getString("xxx") 一个没落。
换句话说,连接池让程序跑得更快了,但开发者的手更酸了。
这一篇,我们来看 Spring 如何用 模板方法模式,把 JDBC 操作中那些“重复但不得不写”的代码彻底干掉。
学习目标
- 理解
JdbcTemplate如何封装了 JDBC 的重复代码(资源管理、异常转换) - 掌握
JdbcTemplate的核心方法(query、update、execute)的使用 - 理解 Spring 的
DataAccessException体系——为何要包装SQLException - 理解
JdbcTemplate与 MyBatis 的定位差异
正文
一、从原生 JDBC 到 JdbcTemplate:并排对比
我们直接看代码。同样的功能——根据 ID 查询用户——用原生 JDBC 和 JdbcTemplate 分别怎么写。
左边:原生 JDBC(约 35 行)
public User getUserById(int id) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
User user = null;
try {
conn = DriverManager.getConnection(URL, USER, PASSWORD);
String sql = "SELECT id, username, email, age FROM users WHERE id = ?";
pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, id);
rs = pstmt.executeQuery();
if (rs.next()) {
user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
if (rs != null) { try { rs.close(); } catch (SQLException e) {} }
if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) {} }
if (conn != null) { try { conn.close(); } catch (SQLException e) {} }
}
return user;
}
右边:JdbcTemplate(约 12 行)
public User getUserById(int id) {
String sql = "SELECT id, username, email, age FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id}, (rs, rowNum) -> {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
return user;
});
}
代码量减少了三分之二。但这只是表面。真正的变化在于:
- 资源管理:
Connection、PreparedStatement、ResultSet的创建和关闭,全部由JdbcTemplate接管。你不再需要写finally块。 - 异常处理:
SQLException被转换为 Spring 的DataAccessException(非受检异常),不再强制try-catch。 - 结果映射:通过
RowMapper将结果集映射逻辑集中在一处,清晰且可复用。
JdbcTemplate 并没有“消灭”JDBC,它只是把那些“每次都一样”的步骤封装了起来,让你只关注“每次不一样”的部分。
二、模板方法模式:定义骨架,让子类填充变化
JdbcTemplate 的设计背后是一个经典的设计模式——模板方法模式(Template Method Pattern)。
这个模式的核心思想是:在父类中定义一个操作的整体骨架,把某些步骤延迟到子类中实现。
听起来抽象,我们看 JdbcTemplate 的实际代码就明白了。
JdbcTemplate 中有一个核心方法 execute,它是所有其他方法的基础:
// JdbcTemplate 的核心骨架(简化版)
public <T> T execute(PreparedStatementCreator psc, PreparedStatementCallback<T> action)
throws DataAccessException {
// 1. 获取连接(由模板负责)
Connection con = DataSourceUtils.getConnection(obtainDataSource());
PreparedStatement ps = null;
try {
// 2. 创建 PreparedStatement(由模板负责)
ps = psc.createPreparedStatement(con);
// 3. 执行 SQL(由模板负责)
ResultSet rs = ps.executeQuery();
// 4. 处理结果集 —— 这一步留给“回调”来实现
return action.doInPreparedStatement(ps);
} catch (SQLException ex) {
// 5. 异常转换(由模板负责)
throw translateException(ex);
} finally {
// 6. 释放资源(由模板负责)
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(ps);
DataSourceUtils.releaseConnection(con, getDataSource());
}
}
模板方法模式的价值:
- 固定的步骤(获取连接、创建 Statement、释放资源)由模板类实现,不会变
- 变化的步骤(SQL 怎么写、结果怎么映射)通过回调接口(如
PreparedStatementCallback、RowMapper)交给调用者实现 - 调用者只需要关注“业务逻辑”,不需要关心“模板代码”
你平时用的 queryForObject、update、batchUpdate 等方法,底层都是基于这个 execute 骨架构建的。
三、RowMapper:结果集映射的优雅方案
在原生 JDBC 中,结果集映射是最繁琐的部分。JdbcTemplate 用 RowMapper 解决了这个问题。
RowMapper 是一个函数式接口:
@FunctionalInterface
public interface RowMapper<T> {
T mapRow(ResultSet rs, int rowNum) throws SQLException;
}
使用方式一:匿名内部类(Java 8 之前的老写法)
List<User> users = jdbcTemplate.query(sql, new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
return user;
}
});
使用方式二:Lambda 表达式(Java 8+,推荐)
List<User> users = jdbcTemplate.query(sql, (rs, rowNum) -> {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
return user;
});
使用方式三:复用 RowMapper 实例(最佳实践)
如果同一个映射逻辑在多处使用,可以定义为静态常量,避免重复创建:
public class UserRowMapper implements RowMapper<User> {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
user.setCreateTime(rs.getTimestamp("create_time"));
return user;
}
}
// 使用
List<User> users = jdbcTemplate.query(sql, new UserRowMapper());
四、异常转换体系:为什么要包装 SQLException?
如果你用过原生 JDBC,你一定对 SQLException 有复杂的情感——它什么错误都能抛,但每次抛出来都让你一脸茫然。
SQLException 的问题:
- 受检异常:必须
try-catch,导致代码中到处都是异常处理 - 信息过于笼统:
SQLException包含了错误码、SQLState、链式异常,但开发者往往不知道该怎么处理 - 无法区分“可恢复”和“不可恢复”的异常:连接超时和 SQL 语法错误都包装在同一个异常里
Spring 做了两件事来解决这个问题:
第一件事:将受检异常转换为非受检异常
SQLException(受检)→ DataAccessException(非受检,RuntimeException 的子类)
这意味着你可以选择是否处理异常。对于那些“无法恢复”的异常(比如 SQL 语法错误),你可以不处理,让异常向上抛出,由全局异常处理器统一处理。
第二件事:细化了异常层次结构
Spring 根据 SQLException 的错误码(vendor-specific)将其映射到不同的子类:
| 数据库错误 | Spring 异常 |
|---|---|
| 连接失败 | CannotGetJdbcConnectionException |
| SQL 语法错误 | BadSqlGrammarException |
| 数据重复(唯一约束冲突) | DuplicateKeyException |
| 查询结果为空但期望有值 | EmptyResultDataAccessException |
| 死锁 | DeadlockLoserDataAccessException |
这种细化的异常体系,让你可以针对不同的错误类型做不同的处理。比如:
try {
userService.save(user);
} catch (DuplicateKeyException e) {
// 用户名已存在,给用户友好提示
return "用户名已被占用";
} catch (DataAccessException e) {
// 其他数据库错误,记录日志并返回通用错误
log.error("数据库操作失败", e);
return "系统繁忙,请稍后重试";
}
五、JdbcTemplate 的局限:为什么还需要 ORM?
JdbcTemplate 已经很好了,但它仍然存在一些局限:
局限一:SQL 仍然是硬编码的
SQL 语句依然写在 Java 代码中。如果 SQL 需要修改,你还是得修改 Java 代码、重新编译、重新部署。
局限二:结果映射仍需手动编码
虽然 RowMapper 比原生 JDBC 优雅很多,但每个字段你还是要手动 rs.getString("xxx")。如果一张表有 50 个字段,你还是得写 50 行映射代码。
局限三:对象关系映射(ORM)能力缺失
- 关联查询的结果无法自动封装成嵌套的对象结构(比如
User中包含List<Order>) - 无法通过“对象操作”自动生成 SQL(比如
userDao.save(user))
这就是为什么还需要 MyBatis 和 JPA/Hibernate 这样的 ORM 框架。
定位差异:
| 维度 | JdbcTemplate | MyBatis | JPA/Hibernate |
|---|---|---|---|
| 定位 | 增强型 JDBC | 半自动 ORM | 全自动 ORM |
| SQL 编写 | Java 代码中写 SQL | XML 或注解中写 SQL | 自动生成 SQL |
| 结果映射 | 手动 RowMapper |
自动映射或 ResultMap |
自动映射(约定配置) |
| 控制力 | ⭐⭐⭐⭐⭐ 完全控制 SQL | ⭐⭐⭐⭐ 精细控制 | ⭐⭐ 黑盒较多 |
| 学习曲线 | 低 | 中 | 高 |
| 适用场景 | 简单查询、快速开发 | 复杂 SQL、需要精细优化 | 标准 CRUD、领域模型驱动 |
简单说:JdbcTemplate 是“原始 JDBC 的语法糖”,而 MyBatis 是“SQL 与 Java 代码的分离工具”。
代码示例
示例一:使用 JdbcTemplate 完成 CRUD
这是 Spring Boot 3.x 环境下的完整示例。
项目依赖(pom.xml 核心部分):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
配置(application.yml):
spring:
datasource:
url: jdbc:mysql://localhost:3306/testdb?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
DAO 层实现:
package com.example.demo.dao;
import com.example.demo.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.util.List;
@Repository
public class UserDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 查询所有用户 —— 使用 BeanPropertyRowMapper(自动映射)
* 要求:数据库字段名和 Java 属性名一致(或开启驼峰映射)
*/
public List<User> findAll() {
String sql = "SELECT id, username, email, age, create_time FROM users";
// BeanPropertyRowMapper 通过反射自动将 ResultSet 的列名映射到 User 的属性
return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(User.class));
}
/**
* 根据 ID 查询用户 —— 使用自定义 RowMapper
* 适用场景:字段名不一致,或需要特殊转换
*/
public User getById(int id) {
String sql = "SELECT id, username, email, age, create_time FROM users WHERE id = ?";
try {
return jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper());
} catch (EmptyResultDataAccessException e) {
return null; // 查询不到返回 null
}
}
/**
* 插入用户 —— 获取自动生成的主键
*/
public User save(User user) {
String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
ps.setString(1, user.getUsername());
ps.setString(2, user.getEmail());
ps.setInt(3, user.getAge());
return ps;
}, keyHolder);
// 设置自动生成的主键
Number key = keyHolder.getKey();
if (key != null) {
user.setId(key.intValue());
}
return user;
}
/**
* 更新用户
*/
public int update(User user) {
String sql = "UPDATE users SET username = ?, email = ?, age = ? WHERE id = ?";
return jdbcTemplate.update(sql,
user.getUsername(),
user.getEmail(),
user.getAge(),
user.getId()
);
}
/**
* 删除用户
*/
public int deleteById(int id) {
String sql = "DELETE FROM users WHERE id = ?";
return jdbcTemplate.update(sql, id);
}
/**
* 统计用户总数
*/
public int count() {
String sql = "SELECT COUNT(*) FROM users";
return jdbcTemplate.queryForObject(sql, Integer.class);
}
/**
* 自定义 RowMapper —— 可复用
*/
private static class UserRowMapper implements RowMapper<User> {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
Timestamp createTime = rs.getTimestamp("create_time");
if (createTime != null) {
user.setCreateTime(createTime.toLocalDateTime());
}
return user;
}
}
}
关键点:
BeanPropertyRowMapper可以自动映射大部分场景,但要求字段名完全匹配(或开启spring.jdbc.template.auto-generate-key的驼峰转换)queryForObject在查询不到数据时会抛出EmptyResultDataAccessException,需要用try-catch处理KeyHolder用于获取数据库自动生成的主键(如 MySQL 的自增 ID)
示例二:批量操作 —— 一次性插入 1000 条数据
package com.example.demo.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class BatchService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 批量插入 1000 条用户数据
* 使用 batchUpdate 将多条 SQL 合并为一次网络请求发送
*/
@Transactional
public void batchInsert(int count) {
String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";
// 准备批量参数
List<Object[]> batchArgs = new ArrayList<>();
for (int i = 0; i < count; i++) {
batchArgs.add(new Object[]{
"user_" + i,
"user_" + i + "@example.com",
20 + (i % 10)
});
}
// 执行批量插入 —— 返回每个 SQL 影响的行数数组
int[] result = jdbcTemplate.batchUpdate(sql, batchArgs);
System.out.println("批量插入完成,影响行数: " + result.length);
}
/**
* 对比:单条插入 —— 慢得多
*/
public void singleInsert(int count) {
String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";
for (int i = 0; i < count; i++) {
jdbcTemplate.update(sql, "user_" + i, "user_" + i + "@example.com", 20 + (i % 10));
}
}
}
性能差异:单条插入 1000 条数据,每条都是一次网络往返(约 1ms),总耗时约 1 秒。批量插入将 1000 条 SQL 合并为一次网络请求发送,总耗时约 10-20ms。差距可达 50-100 倍。
新手错误 vs 正确姿势
| 错误表象 | 根本原因 | 正确姿势 |
|---|---|---|
在 JdbcTemplate 中拼接 SQL 字符串,使用 + 拼接参数 |
未使用参数化占位符,既可能导致 SQL 注入,也容易写错引号 | 使用 ? 占位符,参数通过数组或可变参数传递 |
每次查询都创建一个新的 RowMapper 匿名内部类 |
未复用 RowMapper 实例,造成不必要的对象创建 |
将 RowMapper 定义为静态内部类或静态常量,或使用 BeanPropertyRowMapper |
queryForObject 查不到数据时抛出异常,未处理导致 500 |
未理解 queryForObject 的语义——期望有且仅有一条数据 |
使用 try-catch 捕获 EmptyResultDataAccessException,或改用 query + 判空 |
| 批量操作未开启事务,部分成功部分失败 | 每条 SQL 独立提交,第一条成功了、第二条失败了,第一条不会回滚 | 使用 @Transactional 确保批量操作的原子性 |
疑难深度追问
Q1:JdbcTemplate 是线程安全的吗?
是。 JdbcTemplate 不持有任何状态(stateless)。它只在方法执行时从 DataSource 获取连接,执行完立即归还。所有局部变量都在方法栈中,线程之间完全隔离。所以你可以(也应该)将 JdbcTemplate 声明为单例,在多线程环境中安全共享。Spring 默认就是这么做的——注入的 JdbcTemplate 就是一个单例 Bean。
Q2:为什么 Spring 不直接把 JdbcTemplate 作为唯一的数据库访问方案?
因为 JdbcTemplate 解决的只是“JDBC 操作太繁琐”的问题,但没有解决“对象关系映射”的问题。在复杂的企业应用中:
- 一个
Order对象可能关联List<OrderItem>,JdbcTemplate无法自动处理这种嵌套关系 - 多表关联查询的结果集,用
RowMapper手动组装非常痛苦 - 分页查询、多租户、软删除等通用需求,
JdbcTemplate没有内置支持
这些场景需要更专业的框架(MyBatis、JPA)来处理。JdbcTemplate 的设计定位就是轻量级的 JDBC 增强工具,而不是一个完整的 ORM 框架。
Q3:BeanPropertyRowMapper 的自动映射是怎么实现的?为什么有时候映射不上?
BeanPropertyRowMapper 在底层使用 Java 内省(Introspection) 机制:
- 通过
ResultSetMetaData获取查询结果的列名 - 将列名转换为 JavaBean 的属性名(下划线转驼峰,如
user_name→userName) - 通过反射调用
setXxx()方法完成赋值
映射不上最常见的原因:
- 列名是
user_name,但 Java 属性是username(少了下划线对应的驼峰)——需要开启spring.jdbc.template.auto-generate-key的命名策略 - Java 类没有对应的
setXxx()方法 - Java 属性的类型和数据库字段类型不兼容(如
LocalDateTime和TIMESTAMP,但绝大多数场景下BeanPropertyRowMapper支持直接映射)
思考与延伸
-
对比实验:用原生 JDBC、
JdbcTemplate分别实现一个复杂的多表关联查询,对比代码行数和可读性。然后试着用RowMapper组装一个包含List<Order>的User对象,感受一下 ORM 的必要性。 -
思考题:
JdbcTemplate使用了模板方法模式,而NamedParameterJdbcTemplate是对JdbcTemplate的封装。这种“嵌套封装”的设计模式叫什么?(提示:装饰者模式) -
动手改造:如果你现在用的是 MyBatis-Plus,试着在一个查询中改用
JdbcTemplate来实现,对比两种方式的开发效率和代码可维护性。你会发现各有千秋——JdbcTemplate写简单查询非常快,但复杂查询的 SQL 拼接远不如 MyBatis XML 优雅。
参考与延伸阅读
- Spring Framework. JdbcTemplate API Documentation. Spring Framework Javadoc, 6.0.x
- Spring Framework. DataAccessException Hierarchy. Spring Framework Javadoc
- Baeldung. Spring JdbcTemplate Example. Baeldung, 2024
- 阿里云开发者社区. JdbcTemplate 与 MyBatis 对比分析. 2023
- 腾讯云. Spring JdbcTemplate 使用指南. 腾讯云开发者社区
更多推荐




所有评论(0)