从 mini-mybatis 到 mini-mybatis-spring:理解 MyBatis 和 Spring 集成原理
学习 MyBatis 时,如果一上来就看 SqlSession、MapperProxy、MappedStatement,很容易觉得这些概念是凭空出现的。
更自然的理解路径应该是:
text
JDBC
-> DAO 层
-> 手写 DAO 实现类的重复劳动
-> MyBatis -> mybatis-spring
因为 MyBatis 本质上不是"发明了一种新的数据库访问方式",而是站在 JDBC 和 DAO 模式之上,把大量重复、机械、容易出错的代码框架化了。
平时使用 MyBatis 时,我们最熟悉的代码大概是这样的:
java
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.findById(1L);
在 Spring 项目里,这段代码通常进一步变成:
java
@Service
class UserService {
private final UserMapper userMapper;
UserService(UserMapper userMapper) { this.userMapper = userMapper; }}
看起来像是在调用一个普通 Java 接口,但背后真正发生的是:
text
Mapper 接口方法
-> 定位 SQL -> 解析参数
-> 执行 JDBC -> 映射 ResultSet -> 返回 Java 对象
这篇笔记基于一个学习项目来拆解这条链路。项目分为两个模块:
text
mini-mybatis-parent
├── mini-mybatis 原生 MyBatis 核心机制
└── mini-mybatis-spring mini 版 mybatis-spring 适配层
它不是完整 MyBatis,也不是完整 mybatis-spring。它刻意保留主干逻辑,删掉动态 SQL、缓存、插件、复杂事务同步、异常翻译等高级能力,让我们可以更清楚地看见核心原理。
从 JDBC 开始:最原始的问题
Java 访问数据库的基础是 JDBC。最直接的查询代码大概长这样:
java
String sql = "select id, email, name, age from users where id = ?";
try (Connection connection = dataSource.getConnection();
PreparedStatement ps = connection.prepareStatement(sql)) { ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { return null; } User user = new User(); user.setId(rs.getLong("id")); user.setEmail(rs.getString("email")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); return user; }}
这段代码非常清楚,也非常底层。它暴露了几个问题:
- 每个查询都要写获取连接、创建
PreparedStatement、关闭资源。 - 每个 SQL 都要手动按顺序设置参数。
- 每个查询结果都要手动从
ResultSet映射到对象。 - SQL 字符串、参数绑定、对象映射混在一起,业务代码会越来越重。
如果项目里只有几条 SQL,这不是问题。但真实业务里有几十、几百个表和查询时,这些样板代码会迅速膨胀。
所以第一个抽象自然出现了:DAO 层。
DAO 层:把数据访问从业务里拆出去
DAO,也就是 Data Access Object,核心思想很朴素:
text
业务层不直接写 JDBC。
业务层只依赖一个数据访问接口。
具体数据库访问细节放到 DAO 实现类里。
例如:
java
public interface UserDao {
User findById(long id);}
业务层只关心:
java
User user = userDao.findById(1L);
至于底层怎么查数据库,交给实现类:
java
public class JdbcUserDao implements UserDao {
private final DataSource dataSource;
public User findById(long id) { String sql = "select id, email, name, age from users where id = ?"; // JDBC: getConnection / prepareStatement / setLong / executeQuery / ResultSet mapping }}
DAO 模式带来了一个重要好处:业务层和数据库访问细节解耦。
但它没有消除 JDBC 重复劳动。每个 DAO 实现类里还是会反复出现:
text
获取连接
创建 PreparedStatement绑定参数
执行 SQL遍历 ResultSet映射 JavaBean关闭资源
于是问题变成:
text
DAO 接口是稳定的,SQL 是开发者想自己控制的;
但 DAO 实现类里大量 JDBC 模板代码是重复的。
能不能只写 DAO 接口和 SQL,不写 DAO 实现类?
这就是理解 MyBatis 的入口。
MyBatis 的位置:自动生成 DAO 实现
从 DAO 的角度看,MyBatis 做的事情可以这样理解:
text
你写:
UserMapper 接口
SQL 注解或 XML
MyBatis 负责:
生成接口代理 执行 JDBC 绑定参数
映射结果
所以 MyBatis 的 Mapper 接口,其实可以看成 DAO 接口的一种演进:
java
public interface UserMapper {
User findById(long id);}
区别是:传统 DAO 需要你手写 JdbcUserDao 实现类;MyBatis 用动态代理在运行期帮你"补上"这个实现类。
于是原来手写 DAO 实现类里的逻辑:
text
findById()
-> SQL -> PreparedStatement -> 参数绑定
-> ResultSet -> User
被 MyBatis 拆成了几个框架组件:
| DAO 实现类里的工作 | MyBatis 中的组件 |
|---|---|
| 找到要执行的 SQL | MappedStatement / Configuration |
| 实现 DAO 接口方法 | MapperProxy |
绑定 PreparedStatement 参数 |
SqlSourceParser / ParamNameResolver |
| 执行 JDBC | Executor |
| 映射查询结果 | ResultSet 映射 / PropertyAccessor |
这样再看 MyBatis,就不是凭空多出来一堆类,而是把手写 JDBC DAO 实现类拆成了框架内部的几个职责。
先看两张架构图
第一张是原生 mini-mybatis 的宏观分层。它回答的是:一次 Mapper 接口调用,如何一路走到 JDBC?
第二张是 mini-mybatis-spring 的宏观分层。它回答的是:Spring 容器如何把 Mapper 接口变成可注入的 Bean?
所以可以先记住这两个核心结论:
text
mini-mybatis:负责把 Mapper 方法调用变成 SQL 执行。
mini-mybatis-spring:负责把 Mapper 代理注册成 Spring Bean。
一、MyBatis 的核心问题
基于 JDBC 和 DAO 的脉络,MyBatis 要解决的核心问题可以概括成一句话:
text
开发者保留 DAO 接口和 SQL 控制权,框架接管 JDBC 模板代码和 DAO 实现类。
所以它至少需要解决四件事:
- 怎么保存"DAO/Mapper 接口方法"和"SQL"的对应关系?
- 怎么让一个没有实现类的 Mapper 接口可以被调用?
- 怎么把
#{id}这样的参数占位符绑定到PreparedStatement? - 怎么把
ResultSet映射成 Java 对象?
如果换成传统 JDBC DAO 的语言,也就是:
text
Mapper 接口 = DAO 接口
MapperProxy = DAO 实现类的运行期替代品
MappedStatement = DAO 方法背后的 SQL 元数据
Executor = 被框架抽出来的 JDBC 模板流程
在 mini-mybatis 模块里,这四件事分别对应:
| 问题 | mini-mybatis 中的类 |
|---|---|
| 保存接口方法和 SQL 的对应关系 | MappedStatement、Configuration |
| Mapper 接口如何被调用 | DefaultSqlSession、MapperProxy |
| SQL 参数如何绑定 | SqlSourceParser、BoundSql、ParamNameResolver |
| 查询结果如何映射 | SimpleExecutor、PropertyAccessor |
二、MappedStatement:一条 SQL 的元信息
MyBatis 不是执行时才去 XML 或注解里临时找 SQL。更合理的方式是:启动或构建阶段先把 SQL 解析出来,保存成统一的元信息。
在这个项目里,这个元信息叫:
java
public final class MappedStatement {
private final String id; private final SqlCommandType commandType; private final String sql; private final Class<?> resultType;}
它描述的是"一条可执行 SQL":
text
id = com.example.UserMapper.findById
commandType = SELECT
sql = select id, name from users where id = #{id}
resultType = User.class
所有 MappedStatement 会注册到 Configuration:
text
Configuration
-> Map<String, MappedStatement>
后面执行 Mapper 方法时,只要拼出 statementId,就能从 Configuration 里找到对应 SQL。
三、statementId:接口方法和 SQL 的桥
Mapper 接口通常长这样:
java
public interface UserMapper {
@Select("select id, email, name, age from users where id = #{id}") User findById(@Param("id") long id);}
XML Mapper 则长这样:
xml
<mapper namespace="com.example.UserMapper">
<select id="findById" resultType="com.example.User"> select id, email, name, age from users where id = #{id} </select></mapper>
无论注解还是 XML,最终都会归一成同一个规则:
text
statementId = 接口全名 + "." + 方法名
例如:
text
com.example.UserMapper.findById
这就是 Mapper 方法和 SQL 之间的桥。
四、SqlSession:用户入口
SqlSession 是原生 MyBatis 暴露给用户的主要入口。
在 mini-mybatis 中,它的能力很小:
java
public interface SqlSession extends AutoCloseable {
<T> T getMapper(Class<T> mapperType);
<T> T selectOne(String statementId, Object parameter);
<T> List<T> selectList(String statementId, Object parameter);
int insert(String statementId, Object parameter);
int update(String statementId, Object parameter);
int delete(String statementId, Object parameter);}
它有两种用法。
第一种是直接按 statementId 执行:
java
session.selectOne("com.example.UserMapper.findById", 1L);
第二种是获取 Mapper:
java
UserMapper mapper = session.getMapper(UserMapper.class);
mapper.findById(1L);
日常开发里我们更常用第二种,因为它更类型安全,也更像普通业务代码。
五、MapperProxy:接口为什么能执行
Mapper 是接口,没有实现类:
java
public interface UserMapper {
User findById(long id);}
那为什么能调用?
答案是 JDK 动态代理。
在 DefaultSqlSession#getMapper() 里,会创建一个代理对象:
java
Object proxy = Proxy.newProxyInstance(
mapperType.getClassLoader(), new Class<?>[]{mapperType}, new MapperProxy<>(this, mapperType));
业务代码调用:
java
mapper.findById(1L);
其实会进入:
java
MapperProxy#invoke(...)
它的核心逻辑是:
text
1. 根据接口类型和方法名拼出 statementId2. 把 Java 方法参数整理成 parameterObject3. 从 Configuration 获取 MappedStatement4. 判断 SQL 类型和返回值类型
2. 委托 SqlSession 执行 selectOne/selectList/update
所以 Mapper 接口不是"真的有实现类",而是在运行时由代理对象拦截方法调用,再转成框架内部的 SQL 执行。
六、参数是怎么绑定的
MyBatis 里我们常写:
sql
select * from users where id = #{id}
JDBC 不能直接执行 #{id},它需要的是:
sql
select * from users where id = ?
然后再调用:
java
preparedStatement.setObject(1, id);
在 mini-mybatis 里,这一步由 SqlSourceParser 完成。
它把原始 SQL:
sql
select id, name from users where id = #{id}
解析成 BoundSql:
text
sql = select id, name from users where id = ?
parameterPaths = ["id"]
如果 SQL 是:
sql
insert into users(id, name) values (#{user.id}, #{user.name})
那么参数路径就是:
text
["user.id", "user.name"]
真正绑定参数时,SimpleExecutor 会按顺序读取这些路径:
text
id
user.id
user.name
param1.name
路径读取由 PropertyAccessor 完成。它支持从 Map 里取值,也支持从 JavaBean 的 getter 或字段里取值。
七、方法参数如何变成 parameterObject
Mapper 方法参数长这样:
java
User findByEmailAndAge(@Param("email") String email, @Param("age") int age);
SQL 里写的是:
sql
where email = #{email} and age = #{age}
这中间需要一个参数名解析过程。
在项目里,这个过程由 ParamNameResolver 完成。
它会把多个参数转成 Map:
text
email -> 参数值
age -> 参数值
param1 -> 第一个参数值
param2 -> 第二个参数值
如果方法只有一个参数,并且没有 @Param,就直接把这个参数作为根对象。
这就是为什么下面两种写法都能工作:
java
User findById(@Param("id") long id);
sql
where id = #{id}
以及:
java
int insert(@Param("user") User user);
sql
values (#{user.id}, #{user.name})
八、Executor:真正执行 JDBC 的地方
SimpleExecutor 是真正接触 JDBC 的地方。
查询流程是:
text
MappedStatement
-> SqlSourceParser 解析成 BoundSql -> DataSource 获取 Connection -> 创建 PreparedStatement -> 按 parameterPaths 绑定参数
-> executeQuery -> ResultSet 映射成 Java 对象
更新流程类似,只是最后调用 executeUpdate,返回影响行数。
在完整 MyBatis 中,Executor 还会处理一级缓存、二级缓存、批处理、延迟加载、插件拦截等复杂逻辑。这个项目里只保留最直观的 JDBC 主线。
九、结果集如何映射成对象
查询得到的是 ResultSet,业务代码想要的是 User。
项目里的映射规则很简单:
text
简单类型:返回第一列
JavaBean:按列名找 setter 或字段
例如 SQL 返回:
sql
select id, email, name, age from users
就会依次调用:
text
setId(...)
setEmail(...)
setName(...)
setAge(...)
同时支持简单的下划线转驼峰:
text
user_name -> userName
这部分对应完整 MyBatis 中更强大的 ResultMap、TypeHandler、MetaObject 等机制。
十、原生 mini-mybatis 的整体链路
把上面串起来,一次 Mapper 调用大致是:
text
mapper.findById(1L)
-> MapperProxy.invoke() -> statementId = UserMapper.findById -> Configuration.getMappedStatement(statementId) -> ParamNameResolver 解析参数
-> SqlSession.selectOne() -> SimpleExecutor.query() -> SqlSourceParser 解析 #{} -> PreparedStatement 绑定参数
-> JDBC 执行
-> ResultSet 映射成 User -> 返回给业务代码
这就是 MyBatis "接口调用即 SQL 执行"的核心。
十一、Spring 集成解决了什么
原生 MyBatis 通常这样用:
java
try (SqlSession session = factory.openSession()) {
UserMapper mapper = session.getMapper(UserMapper.class); mapper.findById(1L);}
但在 Spring 项目里,我们更希望:
java
@Service
class UserService {
private final UserMapper userMapper;
UserService(UserMapper userMapper) { this.userMapper = userMapper; }}
也就是说,Spring 集成要解决的问题是:
text
如何把 MyBatis 的 Mapper 代理变成 Spring Bean?
这就是 mybatis-spring 的核心价值。
在 mini-mybatis-spring 模块里,保留了几个关键类:
| 类 | 作用 |
|---|---|
SqlSessionFactoryBean |
把 Spring 配置转换成 MiniSqlSessionFactory |
SqlSessionTemplate |
Spring 单例安全的 SqlSession 门面 |
MapperFactoryBean |
把 Mapper 接口暴露成 Spring Bean |
@MapperScan |
扫描 Mapper 接口并注册 MapperFactoryBean |
十二、SqlSessionFactoryBean:接入 Spring 生命周期
MiniSqlSessionFactory 原本要这样创建:
java
MiniSqlSessionFactory factory = new MiniSqlSessionFactoryBuilder()
.dataSource(dataSource) .addMapper(UserMapper.class) .addXmlMapper("mapper/UserXmlMapper.xml") .build();
但 Spring 更习惯用 Bean 配置来声明依赖:
java
@Bean
SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperInterfaces(new Class<?>[]{UserMapper.class}); factoryBean.setMapperLocations(new String[]{"mapper/UserMapper.xml"}); return factoryBean;}
SqlSessionFactoryBean 实现了 Spring 的 FactoryBean<MiniSqlSessionFactory>。
这意味着:
text
Spring 管理的是 SqlSessionFactoryBean真正暴露出去的是 getObject() 返回的 MiniSqlSessionFactory
所以它是一层适配器:
text
Spring Bean 生命周期
-> afterPropertiesSet() -> MiniSqlSessionFactoryBuilder -> MiniSqlSessionFactory
为什么要包这一层?因为 MyBatis 的工厂构建流程不属于 Spring 原生 Bean 创建方式,需要一个桥把两边接起来。
十三、SqlSessionTemplate:为什么需要 Template
如果直接把原生 SqlSession 放进 Spring 单例 Bean,会有生命周期问题。
在完整 MyBatis 中,SqlSession 通常代表一次数据库会话,它不适合被多个线程长期共享。
所以 mybatis-spring 提供了 SqlSessionTemplate。
在这个项目里,SqlSessionTemplate 的策略很简单:
text
每次调用
-> sqlSessionFactory.openSession() -> 委托原生 SqlSession 执行
-> close()
它自己可以是 Spring 单例,但真正的原生 SqlSession 是短生命周期的。
这让下面这种注入方式变得安全:
java
@Bean
SqlSessionTemplate sqlSessionTemplate(MiniSqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);}
真实 mybatis-spring 的 SqlSessionTemplate 还会接入 Spring 事务管理。比如同一个事务中复用同一个 SqlSession,事务结束再统一提交和关闭。这个项目里先省略了事务同步,只保留门面和委托的主线。
十四、MapperFactoryBean:接口变 Bean 的关键
Spring 可以创建普通 class:
java
new UserService(...)
但不能直接创建接口:
java
new UserMapper() // 不可能
所以需要 MapperFactoryBean。
它实现:
java
FactoryBean<T>
作用是:
text
Spring 实例化 MapperFactoryBeanMapperFactoryBean 内部调用 sqlSessionTemplate.getMapper(UserMapper.class)getObject() 返回 Mapper 代理
Spring 把这个代理作为 UserMapper Bean 暴露出去
所以当你写:
java
UserMapper mapper = context.getBean(UserMapper.class);
拿到的其实不是 MapperFactoryBean,而是它 getObject() 返回的 Mapper 代理。
十五、MapperScan:自动注册 MapperFactoryBean
手写每个 Mapper 的 MapperFactoryBean 很重复:
java
@Bean
MapperFactoryBean<UserMapper> userMapper(SqlSessionTemplate sqlSessionTemplate) {
MapperFactoryBean<UserMapper> factoryBean = new MapperFactoryBean<>(UserMapper.class); factoryBean.setSqlSessionTemplate(sqlSessionTemplate); return factoryBean;}
所以需要扫描:
java
@Configuration
@MapperScan("com.example.mapper")
class AppConfig {
}
在 mini-mybatis-spring 中,扫描链路是:
text
@MapperScan
-> @Import(MapperScannerRegistrar.class) -> Spring 回调 ImportBeanDefinitionRegistrar -> MapperScannerRegistrar 读取 basePackages -> SimpleMapperScanner 扫描接口
-> 为每个接口注册 MapperFactoryBean 的 BeanDefinition
这里的关键是 Spring 的扩展点:
java
ImportBeanDefinitionRegistrar
它允许我们在 Spring 容器启动早期动态注册 BeanDefinition。
扫描到 UserMapper 后,注册进去的不是:
text
UserMapper.class
因为接口不能实例化。
真正注册的是:
text
beanName = userMapper
beanClass = MapperFactoryBean
constructorArg = UserMapper.class
property sqlSessionTemplate = ref("sqlSessionTemplate")
之后 Spring 创建 userMapper Bean 时,走的是 MapperFactoryBean#getObject(),最终拿到 Mapper 代理。
十六、Spring 集成后的整体链路
Spring 容器启动阶段:
text
读取 @Configuration -> 创建 DataSource -> 创建 SqlSessionFactoryBean -> 暴露 MiniSqlSessionFactory -> 创建 SqlSessionTemplate -> @MapperScan 扫描 Mapper 接口
-> 注册 MapperFactoryBean -> 暴露 Mapper 代理 Bean
业务调用阶段:
text
UserService.userMapper.findById(1L)
-> Spring 注入的 Mapper 代理
-> SqlSessionTemplate -> openSession() -> 原生 SqlSession.getMapper() -> MapperProxy.invoke() -> Executor / JDBC -> 返回 User
所以 mybatis-spring 本质上没有改变 MyBatis 的 SQL 执行原理。
它主要做的是:
text
把 MyBatis 对象接入 Spring 容器
把 Mapper 代理注册成 Spring Bean把 SqlSession 生命周期托管起来
十七、这个 mini 项目刻意省略了什么
为了保留主线,这个项目省略了很多完整框架能力。
mini-mybatis 省略了:
- 动态 SQL,如
<if>、<where>、<foreach> - 一级缓存、二级缓存
- 插件机制
- TypeHandler 注册表
- 复杂 ResultMap
- 事务提交回滚
mini-mybatis-spring 省略了:
- Spring 事务同步
SqlSessionUtils- 异常翻译
- 多
SqlSessionFactory/SqlSessionTemplate选择 - Mapper 扫描的复杂过滤条件
- Spring Boot 自动配置
这些省略不是因为它们不重要,而是因为学习时先抓主干更重要。
十八、总结
MyBatis 的核心是:
text
Mapper 接口
+ SQL 元数据
+ 动态代理
+ JDBC 执行
+ 结果映射
mybatis-spring 的核心是:
text
SqlSessionFactoryBean
+ SqlSessionTemplate + MapperFactoryBean + MapperScan
前者解决"接口方法如何变成 SQL 执行",后者解决"Mapper 代理如何变成 Spring Bean"。
如果把两层合起来看,一次最常见的调用可以浓缩成:
text
Service 调用 Mapper -> Spring 注入的是 Mapper 代理
-> Mapper 代理委托 SqlSessionTemplate -> SqlSessionTemplate 打开原生 SqlSession -> 原生 MapperProxy 定位 MappedStatement -> Executor 执行 JDBC -> ResultSet 映射成对象