很多人用了多年 MyBatis,只知道写 Mapper 接口、配 XML,却始终没搞懂: > 接口没有实现类,为什么能直接注入调用? > 一个方法调用,到底是怎么找到 XML 里的 SQL 并执行的? > 本文从JDK 动态代理、MapperProxy、Spring 注入、SQL 元数据查找、JDBC 执行全链路拆解,一次性把底层讲透,精准纠正动态代理调用细节,拒绝模糊表述。
一、开篇灵魂拷问
日常开发中,我们的代码长这样:
@Mapper
public interface UserMapper {
User selectById(Long id);
}
<!-- UserMapper.xml -->
<mapper namespace="com.xxx.mapper.UserMapper">
<select id="selectById" resultType="com.xxx.entity.User">
select * from user where id = #{id}
</select>
</mapper>
然后在 Service 里直接注入使用:
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getById(Long id) {
return userMapper.selectById(id);
}
}
你一定会产生这些疑惑:
-
- UserMapper 是接口,不能实例化,Spring 为什么能注入?
- 2.接口没有方法体,调用
selectById时到底执行了什么代码? -
- 方法名和 XML 中的 SQL 是怎么关联起来的?
-
- 底层到底是不是动态代理?用的 JDK 还是 Cglib?
-
- 代理类中
h.invoke为什么要加super.?super.h到底是什么?
- 代理类中
本文一次性给出最本质、最接近源码、最精准的答案,重点纠正动态代理调用的核心细节。
二、核心结论先行
- MyBatis 采用JDK 动态代理 为所有 Mapper 接口生成代理对象,不存在手动编写的实现类。
- 所有 Mapper 接口的所有方法,最终都进入同一个类
MapperProxy的invoke方法统一处理。 - Mapper 接口本身不包含任何逻辑,仅作用于:编译检查、IDEA 代码提示、调用入口规范。
invoke内部通过“接口全类名 + 方法名”定位 XML 中的 SQL 元数据,再执行底层 JDBC。- Spring 注入的不是接口,而是内存中真实存在的代理实例,完全符合 Spring 依赖注入规则。
三、关键角色一览
在正式看流程前,先认识几个核心类,它们是 MyBatis Mapper 的灵魂:
| 类名 | 作用 |
|---|---|
| MapperProxy | 实现 InvocationHandler,所有 Mapper 的统一代理处理器,是所有方法调用的最终入口 |
| MapperProxyFactory | 用于创建 Mapper 代理对象的工厂,负责初始化 MapperProxy 并生成代理实例 |
| MapperRegistry | Mapper 注册中心,管理所有 Mapper 接口与代理生成,提供 getMapper 方法获取代理实例 |
| MappedStatement | XML 解析后的 SQL 元数据(SQL 文本、参数类型、返回类型、结果映射、节点类型) |
| Configuration | MyBatis 全局配置,持有所有 MappedStatement,供 MapperProxy 查找 SQL 元数据 |
| Proxy(JDK 自带) | 所有 JDK 动态代理类的父类,内部持有 InvocationHandler 实例 h |
四、JDK 动态代理基础:代理类的真实结构
MyBatis 全程只使用 JDK 动态代理,因为 Mapper 是接口——JDK 动态代理天生就是为接口设计的,而 CGLIB 是通过继承类实现代理,不适合接口代理。
JDK 动态代理的核心能力是: 在运行时动态生成字节码,构造一个继承自 Proxy、实现了目标接口的代理类,并加载到内存(Metaspace)中,生成真实的实例对象。
这个代理类(如 $Proxy12)的真实逻辑结构如下(精准版):
// JDK 动态生成的代理类,继承自 Proxy,实现 UserMapper 接口
public final class $Proxy12 extends Proxy implements UserMapper {
// 构造方法(由 JDK 动态生成),传入 InvocationHandler 实例
public $Proxy12(InvocationHandler h) {
super(h); // 将 h 赋值给父类 Proxy 的 h 字段
}
// 实现 UserMapper 接口的 selectById 方法
@Override
public User selectById(Long id) {
try {
// 关键修正:必须用 super.h,获取父类 Proxy 中的 h 实例
// 这个 h 就是 MyBatis 的 MapperProxy 实例
return (User) super.h.invoke(
this, // 当前代理对象
Method对象, // selectById 方法的 Method 实例
new Object[]{id} // 方法参数
);
} catch (Throwable e) {
throw new UndeclaredThrowableException(e);
}
}
}
重点强调(文章核心纠正点):
-
- 代理类
$ProxyXX继承自 java.lang.reflect.Proxy,不是直接实现接口。
- 代理类
-
- 父类
Proxy中有一个protected final InvocationHandler h字段,用于保存代理处理器。
- 父类
-
- 代理类的构造方法会将
MapperProxy实例传入父类,赋值给h。
- 代理类的构造方法会将
-
- 调用接口方法时,必须通过
super.h访问父类的h,进而调用MapperProxy.invoke。
- 调用接口方法时,必须通过
-
- super.h 就是 MyBatis 的 MapperProxy 实例——这是所有 Mapper 方法的最终调度者。
补充验证:你可以通过代码打印代理对象的相关信息,亲眼看到这个关系:
// 打印代理对象的类名
System.out.println(userMapper.getClass()); // 输出:class com.sun.proxy.$Proxy12
// 打印代理对象的父类
System.out.println(userMapper.getClass().getSuperclass()); // 输出:class java.lang.reflect.Proxy
// 验证是否实现了 UserMapper 接口
System.out.println(userMapper instanceof UserMapper); // 输出:true
这证明:代理对象是真实存在的 Java 对象,实现了 UserMapper 接口,继承自 Proxy,完全符合 Spring 注入规则。
五、全局统一入口:MapperProxy 的 invoke 方法(源码级解析)
MyBatis 为所有 Mapper 接口提供了统一的代理处理类——MapperProxy,所有 Mapper 的所有方法,最终都会走到它的 invoke 方法,没有例外。
以下是 MyBatis 源码精简版(保留核心逻辑,去掉无关校验):
public class MapperProxy<T> implements InvocationHandler {
// 被代理的 Mapper 接口(如 UserMapper.class)
private final Class<T> mapperInterface;
// MyBatis 会话,用于执行 SQL
private final SqlSession sqlSession;
// MyBatis 全局配置,持有所有 XML 解析后的 SQL 元数据
private final Configuration config;
// 所有 Mapper 方法的统一入口
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. 构建 SQL 唯一标识:接口全类名 + 方法名(对应 XML 的 namespace + id)
String statementId = mapperInterface.getName() + "." + method.getName();
// 示例:statementId = "com.xxx.mapper.UserMapper.selectById"
// 2. 从全局配置中查找 XML 解析好的 SQL 元数据(MappedStatement)
MappedStatement ms = config.getMappedStatement(statementId);
// 3. 执行 SQL(内部封装了 JDBC 操作)
// 根据 SQL 类型(select/insert/update/delete)调用对应的执行方法
if (ms.getSqlCommandType() == SqlCommandType.SELECT) {
return sqlSession.selectOne(statementId, args);
} else if (ms.getSqlCommandType() == SqlCommandType.INSERT) {
return sqlSession.insert(statementId, args);
}
// 其他 SQL 类型(update/delete)同理
return null;
}
}
核心逻辑总结:
不管你是 UserMapper、OrderMapper、GoodsMapper,不管你调用的是 selectById、insert、update,全部走到这同一个 invoke 方法,通过“接口全类名 + 方法名”定位到 XML 中的 SQL,再执行 JDBC 操作。
六、完整调用链路:从 Service 一行代码到数据库执行
我们以 userMapper.selectById(1L); 为例,完整走一遍真实运行流程,每一步都对应底层真实逻辑:
1. Service 调用方法
开发者写的代码:userMapper.selectById(1L); 看似调用的是 UserMapper 接口方法,实际上调用的是 JDK 动态生成的代理对象 $Proxy12 的 selectById 方法(因为 Spring 注入的是代理实例)。
2. 代理类方法转发
代理类 $Proxy12 的 selectById 方法执行:
return (User) super.h.invoke(this, method, new Object[]{1L});
这里的 super.h 就是 MyBatis 初始化好的 MapperProxy 实例,相当于把方法调用“转发”给 MapperProxy 处理。
3. 进入 MapperProxy.invoke 方法
MapperProxy 拿到三个关键参数:
- proxy:当前代理对象($Proxy12 实例)
- method:当前调用的方法(selectById 的 Method 实例)
- args:方法参数([1L])
第一步构建唯一标识:statementId = "com.xxx.mapper.UserMapper.selectById"。
4. 查找 SQL 元数据
MyBatis 启动时,已经将所有 XML 文件解析为 MappedStatement,并存储在 Configuration 中。 通过 statementId 从 Configuration 中取出对应的 MappedStatement,里面包含:
- SQL 文本:
select * from user where id = #{id} - 参数类型:Long
- 返回类型:com.xxx.entity.User
- 结果映射规则:将数据库字段映射到 User 类的属性
5. 执行 JDBC 操作
SqlSession 内部会封装 JDBC 操作的完整流程:
- 获取数据库连接(从连接池获取)
- 构建 PreparedStatement,替换 SQL 中的 #{id} 为实际参数 1L
- 执行 SQL 查询,获取 ResultSet 结果集
- 将 ResultSet 映射为 User 对象(根据 MappedStatement 中的结果映射规则)
- 关闭连接、释放资源(连接池管理)
6. 返回结果到 Service
将映射好的 User 对象返回给 Service 层,完成整个调用流程。
用一张流程图锁死全链路:
七、为什么 Mapper 接口只是“语法糖”?
理解了上面的流程,你就能彻底明白:Mapper 接口本质上只是一套调用规范与命名约束,没有任何业务逻辑。
它的真实作用只有 3 个:
- 编译期检查:调用不存在的方法、参数类型不匹配,编译时直接报错,避免运行时异常。
- IDEA 代码提示:方法补全、参数提示、跳转 XML,提升开发效率(没有接口,就没有这些提示)。
- 提供方法签名:为 MapperProxy 提供“接口全类名 + 方法名”的唯一标识,用于定位 XML 中的 SQL。
真正的业务逻辑,其实在两个地方:
- XML 中:存储 SQL 语句和结果映射规则。
- MapperProxy.invoke 中:统一调度逻辑,负责查找 SQL、执行 JDBC。
八、Spring 注入为什么不报错?(彻底解惑)
很多开发者有一个误区: “接口不能被注入,Spring 会报错”
错!Spring 的注入规则从来不是“不能注入接口”,而是:
根据注入的类型(如 UserMapper),在 Spring 容器中查找“实现了该接口的实例对象”,找到就注入,找不到才报错。
MyBatis 与 Spring 整合时,会通过 MapperScannerConfigurer 做三件事:
- 扫描 @Mapper 注解或 @MapperScan 配置的包,找到所有 Mapper 接口。
- 通过 MapperProxyFactory 为每个 Mapper 接口生成代理实例($ProxyXX)。
- 将代理实例注册到 Spring 容器中,Bean 的类型就是对应的 Mapper 接口类型。
所以,当你写 @Autowired private UserMapper userMapper; 时,Spring 会在容器中找到“实现了 UserMapper 接口的代理实例”,直接注入——完全符合 Spring 依赖注入规范,不会报错。
九、常见误区纠正(避坑重点)
- 误区 1:“MyBatis 为每个 Mapper 生成不同的代理处理器”——错!所有 Mapper 共用同一个 MapperProxy 类,不同 Mapper 对应不同的 MapperProxy 实例(持有不同的 mapperInterface)。
- 误区 2:“$ProxyXX 类不存在,是虚拟的”——错!它是 JDK 动态生成的真实类,有字节码、有 Class 对象、有实例,存在于内存中,可被 GC 回收。
- 误区 3:“代理类中的 h.invoke 不需要加 super”——错!h 是父类 Proxy 的字段,子类必须用 super.h 访问,否则会报错。
- 误区 4:“MyBatis 可能用 CGLIB 代理 Mapper”——错!Mapper 是接口,CGLIB 是继承类实现代理,无法代理接口,MyBatis 全程只用 JDK 动态代理。
十、全文最精髓总结
- MyBatis 用 JDK 动态代理为 Mapper 接口生成内存中的代理实例($ProxyXX),代理类继承自 Proxy,实现目标 Mapper 接口。
- 代理类中,super.h 就是 MyBatis 的 MapperProxy 实例,所有 Mapper 方法调用都会转发到 MapperProxy.invoke。
- invoke 方法通过“接口全类名 + 方法名”定位 XML 中的 SQL 元数据,再执行 JDBC 操作。
- Mapper 接口仅用于编译检查、代码提示,不包含任何业务逻辑,是一套“命名规范”。
- Spring 注入的是代理实例,不是接口,完全符合依赖注入规则,这也是接口能被注入的核心原因。
十一、结尾
MyBatis 的 Mapper 机制看起来神奇,本质其实非常简单: 用 JDK 动态代理统一调度,用“接口全类名 + 方法名”做 SQL 定位,用 XML 存储 SQL 元数据,最终落地到 JDBC 操作。
理解了 MapperProxy、super.h 的含义,以及整个调用链路,你就理解了 MyBatis 的一半灵魂——剩下的,就是 SQL 解析、结果映射、连接池管理等细节。
如果你用 MyBatis 多年,却一直没搞懂底层原理,这篇文章应该能帮你彻底打通任督二脉。
补充说明:本文基于 MyBatis 3.5.x 源码解析,核心逻辑适用于所有 MyBatis 3.x 版本,与 Spring Boot 整合时流程完全一致,无差异。