数据访问概述
定义与作用
Spring 数据访问层的核心设计哲学是统一抽象。在传统的 Java 数据访问开发中,直接使用 JDBC、JPA 或 MyBatis 时,开发者需要重复处理连接获取、资源释放、异常转换等样板代码。Spring 通过两大机制解决这一痛点:
- 模板方法模式(Template Method Pattern):将资源获取、清理等重复逻辑封装在模板类中,开发者只需关注核心业务 SQL 或持久化操作。
- 统一异常体系:将各持久层技术(JDBC、JPA、Hibernate、MyBatis)的底层异常统一转换为 Spring 的
DataAccessException层次结构,使业务层无需关心具体数据库产品的异常差异。
这种抽象带来的直接收益是:切换底层持久化技术时,业务层代码几乎无需改动。例如从 JDBC 迁移到 JPA,只需更换模板实现,Service 层的异常处理逻辑完全保持不变。
适用位置与常用组件
| 技术 | Spring 抽象 | 作用 | 典型使用位置 |
|---|---|---|---|
| JDBC | JdbcTemplate | 简化原生 JDBC,自动处理 Connection、Statement、ResultSet、异常转换 | @Repository 标注的 DAO 类 |
| JPA | JpaTemplate(已过时)/ EntityManager | 集成 JPA 的 EntityManager 管理 | JPA 环境下的 Repository |
| MyBatis | SqlSessionTemplate | 集成 MyBatis,与 Spring 事务同步 | MyBatis 环境下的 Mapper |
| MongoDB | MongoTemplate | Spring Data MongoDB 的核心操作类 | NoSQL 数据访问层 |
在 Spring 5.x / Spring Boot 2.x 的实际项目中,JdbcTemplate 和 Spring Data JPA 是最常见的两种选择。本章以 JdbcTemplate 为切入点,因为它最能体现 Spring 的模板方法设计思想。
核心原理:模板方法模式与统一异常体系
模板方法模式的结构
Spring 的模板类(如 JdbcTemplate)将数据访问的固定流程抽象为模板,将变化点留给回调接口:
以 JdbcTemplate.query() 为例,其内部执行流程如下:
统一异常体系
Spring 将各持久化技术的异常统一映射到 org.springframework.dao.DataAccessException 下:
关键设计决策:DataAccessException 继承自 RuntimeException。这意味着开发者无需在 DAO 方法上声明 throws 子句,也无需在业务层编写繁琐的 try-catch 块。Spring 认为数据访问异常在大多数情况下是不可恢复的,应当向上传播,由上层事务管理器决定是否回滚。
完整示例:飞翔科技学生管理系统
场景简述
飞翔科技公司的后端开发 小崔 正在为公司内部培训平台开发学生选课模块。需要实现学生信息的增删改查,并确保数据库操作简洁、异常统一。白歌(架构师)要求小崔使用 JdbcTemplate 完成 DAO 层,禁止手写 try-finally 关闭资源。
操作前:数据库表结构与初始数据
-- 学生表
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_id INT NOT NULL COMMENT '学号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
age SHORT COMMENT '年龄',
major VARCHAR(50) COMMENT '专业',
balance FLOAT COMMENT '账户余额(用于选课缴费)'
);
-- 初始数据
INSERT INTO student (student_id, name, age, major, balance) VALUES
(2024001, '大翔', 22, '计算机科学', 6666.66),
(2024002, '白歌', 21, '软件工程', 8888.88),
(2024003, '小崔', 20, '人工智能', 23200.00);
项目依赖(Spring Boot 2.x)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
配置类:DataSource 与 JdbcTemplate
package com.feixiang.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
public class DataAccessConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/feixiang_edu?useSSL=false&serverTimezone=Asia/Shanghai");
config.setUsername("root");
config.setPassword("secret");
config.setMaximumPoolSize(10);
return new HikariDataSource(config);
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
实体类:Student
package com.feixiang.entity;
public class Student {
private Long id;
private int studentId;
private String name;
private short age;
private String major;
private float balance;
// 构造器、getter、setter 省略
public Student() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public int getStudentId() { return studentId; }
public void setStudentId(int studentId) { this.studentId = studentId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public short getAge() { return age; }
public void setAge(short age) { this.age = age; }
public String getMajor() { return major; }
public void setMajor(String major) { this.major = major; }
public float getBalance() { return balance; }
public void setBalance(float balance) { this.balance = balance; }
}
DAO 层:StudentDao(使用 JdbcTemplate)
package com.feixiang.dao;
import com.feixiang.entity.Student;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class StudentDao {
private final JdbcTemplate jdbcTemplate;
private final RowMapper<Student> rowMapper;
@Autowired
public StudentDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
// BeanPropertyRowMapper 自动将列名映射到同名属性
this.rowMapper = new BeanPropertyRowMapper<>(Student.class);
}
// 查询单个学生
public Student findByStudentId(int studentId) {
String sql = "SELECT id, student_id, name, age, major, balance FROM student WHERE student_id = ?";
try {
return jdbcTemplate.queryForObject(sql, rowMapper, studentId);
} catch (EmptyResultDataAccessException e) {
return null; // 查不到返回 null,而非抛出异常
}
}
// 查询所有学生
public List<Student> findAll() {
String sql = "SELECT id, student_id, name, age, major, balance FROM student";
return jdbcTemplate.query(sql, rowMapper);
}
// 更新余额(选课缴费场景)
public int updateBalance(int studentId, float amount) {
String sql = "UPDATE student SET balance = balance - ? WHERE student_id = ? AND balance >= ?";
return jdbcTemplate.update(sql, amount, studentId, amount);
}
// 插入新学生
public int insert(Student student) {
String sql = "INSERT INTO student (student_id, name, age, major, balance) VALUES (?, ?, ?, ?, ?)";
return jdbcTemplate.update(sql,
student.getStudentId(),
student.getName(),
student.getAge(),
student.getMajor(),
student.getBalance());
}
// 删除学生
public int deleteByStudentId(int studentId) {
String sql = "DELETE FROM student WHERE student_id = ?";
return jdbcTemplate.update(sql, studentId);
}
}
Service 层:StudentService(为后续事务章节铺垫)
package com.feixiang.service;
import com.feixiang.dao.StudentDao;
import com.feixiang.entity.Student;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class StudentService {
private final StudentDao studentDao;
@Autowired
public StudentService(StudentDao studentDao) {
this.studentDao = studentDao;
}
public Student getStudent(int studentId) {
return studentDao.findByStudentId(studentId);
}
public List<Student> listAllStudents() {
return studentDao.findAll();
}
public boolean deductBalance(int studentId, float courseFee) {
int affected = studentDao.updateBalance(studentId, courseFee);
return affected > 0;
}
}
操作后:运行结果与数据变化
执行查询操作后,控制台输出:
【飞翔科技学生管理系统 - 查询结果】
学号:2024001,姓名:大翔,专业:计算机科学,余额:6666.66
学号:2024002,姓名:白歌,专业:软件工程,余额:8888.88
学号:2024003,姓名:小崔,专业:人工智能,余额:23200.00
执行扣费操作 deductBalance(2024001, 2000.00f) 后,数据库状态:
| id | student_id | name | age | major | balance |
|---|---|---|---|---|---|
| 1 | 2024001 | 大翔 | 22 | 计算机科学 | 4666.66 |
| 2 | 2024002 | 白歌 | 21 | 软件工程 | 8888.88 |
| 3 | 2024003 | 小崔 | 20 | 人工智能 | 23200.00 |
若余额不足时执行 deductBalance(2024001, 10000.00f),update() 返回 0,Service 层返回 false,不会抛出异常。这是通过 SQL 的 AND balance >= ? 条件实现的乐观判断,而非先查后改。
易错场景与面试考点
反例一:忘记处理 EmptyResultDataAccessException
// 错误写法:直接调用 queryForObject,不处理空结果
public Student findByStudentId(int studentId) {
String sql = "SELECT * FROM student WHERE student_id = ?";
// 当学号不存在时,抛出 EmptyResultDataAccessException
return jdbcTemplate.queryForObject(sql, rowMapper, studentId);
}
问题分析:queryForObject 在结果集为空时会抛出 EmptyResultDataAccessException(属于 DataAccessException 子类)。如果业务语义允许"查不到",应当捕获该异常返回 null,或改用 query() 后判断列表长度。
正确写法:
public Student findByStudentId(int studentId) {
String sql = "SELECT * FROM student WHERE student_id = ?";
List<Student> list = jdbcTemplate.query(sql, rowMapper, studentId);
return list.isEmpty() ? null : list.get(0);
}
反例二:在 DAO 中手动管理 Connection
// 错误写法:回到原生 JDBC 的泥潭
public void badExample() {
Connection conn = null;
PreparedStatement pstmt = null;
try {
conn = dataSource.getConnection();
pstmt = conn.prepareStatement("SELECT * FROM student");
// ... 冗长的资源管理
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
// 极易遗漏关闭,且代码膨胀 3 倍以上
if (pstmt != null) try { pstmt.close(); } catch (SQLException ignored) {}
if (conn != null) try { conn.close(); } catch (SQLException ignored) {}
}
}
问题分析:Spring 的 JdbcTemplate 已经通过模板方法模式将资源管理内聚到框架内部,手动管理不仅代码冗余,还极易在异常路径下泄漏连接,导致连接池耗尽。李眉(运维)凌晨三点被叫起来修服务器,十有八九是连接泄漏。
面试高频考点
Q1:Spring 为什么要将 SQLException 转换为 DataAccessException?
SQLException是受检异常(Checked Exception),强制业务层处理或声明,但大多数数据访问异常不可恢复。Spring 将其转换为非受检的DataAccessException,使业务层代码更干净,同时统一了不同数据库产品的错误码差异(如 MySQL 的1062与 Oracle 的唯一约束异常)。
Q2:JdbcTemplate 的 queryForObject 和 query 有什么区别?
queryForObject期望结果集恰好为 1 行,为空时抛EmptyResultDataAccessException,多于 1 行时抛IncorrectResultSizeDataAccessException。query返回List<T>,允许 0 行、1 行或多行,更适合不确定结果数量的场景。
Q3:Spring 的模板方法模式与经典 GoF 模板方法模式有何不同?
经典模板方法通过继承抽象类并重写钩子方法实现变化点。Spring 的
JdbcTemplate通过回调接口(如RowMapper、PreparedStatementSetter)将变化点外化为策略对象,更符合"组合优于继承"的设计原则,也更适合 Java 的单继承限制。