1. 项目概述:为什么我们要深挖MyBatis的动态代理
如果你用过MyBatis,肯定写过类似UserMapper userMapper = sqlSession.getMapper(UserMapper.class);这样的代码。然后,这个userMapper就能直接调用你在接口里定义的selectUserById方法,神奇地执行了对应的SQL。表面上看,我们调用的是一个接口,但背后却执行了数据库操作。这个“魔法”的核心,就是JDK动态代理。
很多开发者停留在“会用”的层面,知道这么写能工作,但一旦遇到复杂场景,比如插件拦截、多数据源下Mapper的绑定、或者性能调优时,就会感到无力。因为你不清楚这个代理对象是怎么被创建出来的,它如何找到对应的SQL,又是如何把方法调用转化为一次数据库会话的。理解这个原理,不仅仅是应付面试,更是为了在遇到诸如“为什么我的插件没生效?”、“这个方法调用到底走了哪些流程?”这类问题时,能快速定位,而不是盲目地试错。
我自己在早期做性能优化时就踩过坑。当时一个查询方法非常慢,我一开始拼命优化SQL语句,但收效甚微。后来通过Arthas等工具追踪,才发现问题不在SQL本身,而是在MyBatis的代理调用链中,某个自定义插件进行了不必要的重复解析。如果不明白动态代理的机制,我可能永远也找不到这个瓶颈点。
所以,这篇内容的目标不是重复官方文档,而是带你从源码的视角,像解构一台精密仪器一样,把getMapper这个方法从调用开始,到最终返回一个可用的代理对象,这中间每一步的齿轮是如何咬合的,彻底讲明白。我们会聚焦于MyBatis如何利用JDK动态代理,将我们的Mapper接口方法调用,桥接到背后的SQL执行引擎上这一核心链路。
2. 核心脉络:一次Mapper方法调用的全景图
在深入代码之前,我们先建立高层级的认知。整个过程可以简化为一个核心链条:
接口定义 -> 配置解析 -> 代理创建 -> 方法拦截 -> SQL执行
- 接口定义:我们定义一个只有方法声明的Java接口,例如
UserMapper。 - 配置解析:MyBatis启动时,会解析
mybatis-config.xml和所有的Mapper.xml文件(或注解),将接口方法与SQL语句建立映射关系,并把这些信息保存在一个叫做MapperRegistry的核心注册中心里。 - 代理创建:当我们调用
sqlSession.getMapper(UserMapper.class)时,MyBatis会去MapperRegistry查找该接口对应的“工厂”。这个工厂的终极产品,就是一个实现了该接口的动态代理对象。 - 方法拦截:代理对象的所有方法调用,都会被一个特定的
InvocationHandler(在MyBatis中是MapperProxy)拦截。 - SQL执行:在
InvocationHandler的invoke方法里,MyBatis完成了最关键的转换:它将当前调用的方法(Method对象)和传递的参数,转换成一个唯一的标识,然后从配置中取出对应的SQL语句、参数映射等信息,最终委托给Executor去执行数据库操作,并返回结果。
理解了这个链条,我们再钻进去看每个环节的源码实现,就会清晰很多。下面,我们就从起点——配置的加载与注册开始。
3. 源码深潜第一步:Mapper接口的注册与绑定
MyBatis的初始化入口通常是SqlSessionFactoryBuilder.build()方法。它会解析配置文件,最终构建出Configuration对象。这个Configuration是MyBatis的“大脑”,所有配置信息都汇聚于此。其中,管理Mapper接口的核心组件就是MapperRegistry。
3.1 MapperRegistry:Mapper接口的户籍管理处
MapperRegistry可以看作一个注册表,它的核心数据结构是一个Map:
private final Map<Class<?>, MapperProxyFactory<?>> knownMappers = new HashMap<>();键(Key)是我们定义的Mapper接口的Class对象,值(Value)是一个MapperProxyFactory对象。这个工厂就是专门生产该接口动态代理对象的。
那么,接口是如何被注册进去的呢?主要有两种方式:
1. XML配置扫描 (<mappers>标签): 在配置文件中,通过<mappers><mapper resource="..."/></mappers>或<package name="..."/>声明后,MyBatis会使用XMLMapperBuilder来解析对应的XML文件。解析到最后,会调用MapperRegistry.addMapper()方法。
2. 注解接口扫描: 如果你使用纯注解方式(或Spring Boot自动配置),MyBatis会通过ClassPathMapperScanner(在MyBatis-Spring中)或类似的机制,扫描指定包下的接口。对于扫描到的每一个接口,同样会调用MapperRegistry.addMapper()。
3.2 addMapper():注册的核心逻辑
让我们看看MapperRegistry.addMapper(Class<T> type)方法做了什么(代码经过简化,突出主干):
public <T> void addMapper(Class<T> type) { // 1. 检查必须是接口 if (!type.isInterface()) { throw new BindingException("Type " + type + " is not an interface"); } // 2. 检查是否已注册 if (hasMapper(type)) { throw new BindingException("Type " + type + " is already known to the MapperRegistry."); } boolean loadCompleted = false; try { // 3. 将接口Class和对应的MapperProxyFactory放入knownMappers knownMappers.put(type, new MapperProxyFactory<>(type)); // 4. 关键步骤:解析接口(无论是通过关联的XML还是注解) MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type); parser.parse(); loadCompleted = true; } finally { if (!loadCompleted) { knownMappers.remove(type); // 解析失败则移除 } } }关键点解析:
- 第3步:它创建了一个
MapperProxyFactory<T>实例,并以接口Class为键存入Map。注意,此时工厂是“空”的,它只知道要代理哪个接口,但还不知道接口里每个方法对应什么SQL。 - 第4步:
parser.parse()是重头戏。MapperAnnotationBuilder会去解析这个接口:- 如果存在同名的XML映射文件(如
UserMapper.xml),它会解析其中的<select>,<insert>等标签。 - 同时,它也会解析接口方法上的
@Select,@Insert等注解。 - 解析的结果,是为每一个方法生成一个
MappedStatement对象。这个对象包含了该方法的全部执行信息:SQL语句、参数类型、返回类型、语句类型(SELECT/UPDATE等)、缓存配置等。这个MappedStatement会被存入Configuration的另一个Map中,其ID通常是“接口全限定名.方法名”,例如com.example.mapper.UserMapper.selectUserById。
- 如果存在同名的XML映射文件(如
至此,注册完成。Configuration里现在有了两套关键信息:一是MapperRegistry知道UserMapper.class对应哪个工厂;二是有一堆MappedStatement,每个都通过唯一ID关联到了一个具体的方法上。
实操心得:这里常遇到的一个坑是“绑定异常”(BindingException)。比如提示“Invalid bound statement (not found)”。十有八九就是这一步出了问题:要么是XML文件没被扫描到(路径错误、资源过滤问题),要么是接口方法和XML/注解中的SQL语句ID对不上。理解了这个注册过程,你就能迅速定位,是工厂没注册进去,还是
MappedStatement没创建成功。
4. 源码深潜第二步:动态代理对象的诞生——getMapper()
注册完成后,我们就可以在运行时获取Mapper的代理实例了。入口就是SqlSession.getMapper()。
4.1 调用链追踪
通常的调用路径是:DefaultSqlSession.getMapper()->Configuration.getMapper()->MapperRegistry.getMapper()。
我们直接看最核心的MapperRegistry.getMapper():
public <T> T getMapper(Class<T> type, SqlSession sqlSession) { // 1. 从注册表里获取该接口对应的代理工厂 final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type); if (mapperProxyFactory == null) { throw new BindingException("Type " + type + " is not known to the MapperRegistry."); } try { // 2. 使用工厂创建代理实例 return mapperProxyFactory.newInstance(sqlSession); } catch (Exception e) { throw new BindingException("Error getting mapper instance. Cause: " + e, e); } }逻辑很清晰:先查工厂,再用工厂创建。核心在于MapperProxyFactory.newInstance()。
4.2 MapperProxyFactory:代理对象的制造车间
MapperProxyFactory的代码非常精炼:
public class MapperProxyFactory<T> { private final Class<T> mapperInterface; // ... 可能有方法缓存等字段 protected T newInstance(MapperProxy<T> mapperProxy) { // 使用JDK动态代理创建实例 return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); } public T newInstance(SqlSession sqlSession) { // 1. 创建InvocationHandler:MapperProxy final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache); // 2. 调用重载方法创建代理 return newInstance(mapperProxy); } }这就是动态代理创建的核心!
new MapperProxy(...):创建了一个MapperProxy实例。它就是JDK动态代理中关键的InvocationHandler(调用处理器)。它持有了当前SqlSession、Mapper接口的Class对象,以及一个可选的methodCache(用于缓存方法信息,提升性能)。Proxy.newProxyInstance(...):这是JDK标准库的方法。它接收三个参数:ClassLoader:通常使用接口自身的类加载器。Class<?>[] interfaces:要代理的接口列表,这里就是我们的UserMapper.class。InvocationHandler:就是我们上一步创建的MapperProxy实例。
这个方法会返回一个实现了指定接口的代理对象。当我们调用代理对象的任何方法时,都会转而调用其InvocationHandler(即MapperProxy)的invoke方法。
注意事项:这里解释了为什么MyBatis的Mapper必须是接口。因为JDK动态代理的机制就是基于接口的。它无法为没有实现接口的普通类创建代理。这也是MyBatis和一些其他ORM框架(如Hibernate)在设计上的一个区别。
5. 源码深潜第三步:魔法发生的地方——MapperProxy的invoke()
现在,我们拿到了代理对象userMapper。当我们调用userMapper.selectUserById(1)时,程序并不会去执行某个具体的实现类,而是进入了MapperProxy.invoke()方法。这是整个动态代理原理中最精妙的部分。
5.1 invoke方法的主干逻辑
MapperProxy实现了InvocationHandler接口,它的invoke方法大致流程如下(简化版):
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { // 1. 如果是Object类自带的方法(如toString, hashCode等),直接调用,不走代理逻辑 if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 2. 判断是否为接口的默认方法(Java 8+) if (method.isDefault()) { return invokeDefaultMethod(proxy, method, args); } } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } // 3. 核心:获取MapperMethod并执行 final MapperMethod mapperMethod = cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }流程解析:
- 过滤Object方法:代理对象本身也是一个Object,像
toString()、equals()这些方法应该正常执行,不需要被MyBatis的SQL逻辑拦截。 - 处理默认方法:从Java 8开始,接口可以有
default方法。对于这些方法,MyBatis有特殊的处理逻辑(invokeDefaultMethod),让其能正常执行。 - 核心转换与执行:对于我们的业务方法(如
selectUserById),会进入最后一步。这里先通过cachedMapperMethod获取或创建一个MapperMethod对象,然后调用其execute方法。
cachedMapperMethod是一个简单的缓存优化,避免每次调用都重新创建MapperMethod对象。它的核心是调用new MapperMethod(mapperInterface, method, sqlSession.getConfiguration())。
5.2 MapperMethod:方法调用的指挥官
MapperMethod是一个将Java方法调用转化为MyBatis内部命令的关键对象。它在构造时,会做两件至关重要的事情:
- 创建
SqlCommand:根据方法名和接口类型,从Configuration中查找对应的MappedStatement。这个MappedStatement的ID就是我们之前提到的“接口全限定名.方法名”。SqlCommand主要保存了SQL语句的类型(SELECT, INSERT等)和MappedStatement的ID。 - 创建
MethodSignature:解析方法的参数列表、返回类型等信息。它处理了诸如@Param注解、多个参数、集合返回值、Map返回值等复杂情况。
有了这两个信息,MapperMethod.execute()方法就知道“要执行什么命令”以及“如何解析参数和返回值”。
5.3 execute方法:路由到具体的SQL操作
MapperMethod.execute()方法是一个大型的switch-case语句,根据SqlCommand的类型(SELECT, INSERT, UPDATE, DELETE等)和MethodSignature的返回类型,路由到SqlSession的不同方法上。
例如,对于我们的User selectUserById(Integer id)方法:
- 它是一个SELECT语句。
- 返回类型是单个对象 (
User),而不是集合或游标。 - 那么,
execute方法最终会调用sqlSession.selectOne(...)。
selectOne方法内部,会根据MappedStatement的ID找到完整的SQL信息,然后通过Executor(执行器)去完成参数绑定、SQL执行、结果集映射等一系列复杂操作,最终将数据库返回的ResultSet映射成一个User对象并返回。
至此,一个完整的从接口方法调用到SQL执行的闭环就完成了。
常见问题排查技巧:当你发现调用Mapper方法返回null,但数据库明明有数据时,可以沿着这条链路排查:
- MapperMethod层面:检查
execute方法路由是否正确?是不是误走到了selectOne但实际是多个结果?(会抛异常)。或者走到了selectList但返回类型是单个对象?(可能返回List的第一个元素或null)。- SqlSession/Executor层面:检查SQL是否真的执行了?可以通过开启MyBatis日志或使用
mybatis-log-free这类插件查看。参数是否绑定正确?- MappedStatement层面:最根本的,检查
MappedStatement是否正确创建?对应的SQL在XML或注解中是否存在且语法正确?结果映射 (resultMap或resultType) 是否正确配置?这是最常见的问题根源。
6. 动态代理机制带来的扩展性与插件原理
理解了核心代理链路,我们就能明白MyBatis插件(Interceptor)是如何工作的。插件本质上就是利用JDK动态代理(或CGLib),在MyBatis的核心组件(如Executor、StatementHandler、ParameterHandler、ResultSetHandler)外面再包一层代理。
6.1 插件拦截点的位置
以Executor为例。在Configuration创建Executor实例的时候,会调用interceptorChain.pluginAll(executor)。这个方法会遍历所有已配置的插件,让每个插件都有机会使用Plugin.wrap(target, interceptor)方法来对目标对象(Executor)进行包装。
Plugin类本身也实现了InvocationHandler。它的wrap方法会检查插件的@Intercepts注解,如果该插件声明要拦截当前目标对象的方法,那么就使用Proxy.newProxyInstance为目标对象创建一个代理。这个代理的InvocationHandler就是Plugin实例。
6.2 多层代理的调用链
于是,调用链变成了:MapperProxy.invoke()->MapperMethod.execute()->SqlSession.selectOne()->代理后的Executor.query()->Plugin.invoke()-> 执行插件的intercept()方法 ->原始Executor.query()-> ...
可以看到,MyBatis自身的Mapper动态代理,和其插件的动态代理,是两层独立的代理机制。它们协同工作,提供了强大的扩展能力。明白这一点,对于编写高效、正确的插件至关重要。比如,你的插件在intercept方法里,需要调用invocation.proceed()来继续执行链,这其实就是调用了被代理的原始对象的方法。
实操心得:插件开发注意事项
- 签名一定要匹配:
@Signature注解里的type(类)、method(方法名)、args(参数类型)必须精确匹配你要拦截的方法,否则Plugin.wrap()时会跳过,导致插件不生效。- 理解代理链:你的插件只是代理链中的一环。在
intercept方法里,你可以修改传递给下游的参数,也可以修改下游返回的结果。但务必谨慎,避免破坏原有的逻辑。- 性能影响:每加一层代理,就多一次方法调用和反射开销。虽然单次开销很小,但在超高性能要求的场景下,需要评估插件带来的影响。
7. 从原理到实践:解决典型场景问题
掌握了源码原理,我们就能游刃有余地解决一些实际问题。
场景一:如何像Arthas那样,在运行时查看MyBatis生成的SQL?
Arthas的watch或trace命令能抓到SQL,是因为它们通过字节码增强技术,在PreparedStatement执行前后植入了监听代码。从原理上讲,SQL的最终组装是在StatementHandler(特别是PreparedStatementHandler)的parameterize和query方法中完成的。一个更简单的方式是使用MyBatis内置的日志功能(配置logImpl为STDOUT_LOGGING或集成SLF4J+Logback),它会将Executor执行过程中的SQL语句和参数打印出来。理解了代理链,你就知道这些日志是在Executor被插件代理之前还是之后打印的,有助于分析问题。
场景二:MyBatis-Plus等增强工具是如何工作的?
MyBatis-Plus(MP)在保持与MyBatis兼容的同时,提供了更多功能。它核心也是基于MyBatis的扩展点。例如:
- 自己的
MapperProxy:MP可能会继承或重新实现MapperProxy,在invoke方法中,先判断当前调用的方法是否是MP提供的通用方法(如selectById,update),如果是,则走MP自己的逻辑(自动组装SQL);如果不是,则调用父类(原生MyBatis)的逻辑。这解释了为什么我们用MP的BaseMapper,既能调用selectById,也能调用自定义的selectUserByName。 - 自己的
SqlSession或Configuration:MP可以通过自定义的SqlSessionFactoryBean(在Spring中)注入自己增强后的组件。
场景三:多数据源下,Mapper如何正确绑定到不同的SqlSession?
在动态数据源或读写分离场景中,不同的Mapper可能需要不同的SqlSession(背后连接不同的数据库)。从getMapper的源码我们知道,创建代理时传入的sqlSession参数至关重要。在Spring管理下,通常每个数据源会对应一个SqlSessionTemplate(它实现了SqlSession)。Spring的MapperFactoryBean在创建Mapper代理时,会注入与之关联的SqlSessionTemplate。因此,实现多数据源路由的关键,就在于如何让不同的Mapper接口,在创建代理时,拿到正确的SqlSessionTemplate。这通常通过自定义AbstractRoutingDataSource和扫描路径的巧妙划分来实现。
8. 总结与进阶思考
通过这次从源码角度的梳理,我们可以清晰地看到,MyBatis的动态代理开发原理并不神秘,它是一套非常经典且优雅的接口绑定与命令模式的应用。
- 启动时注册:将Mapper接口与对应的
MapperProxyFactory工厂注册到MapperRegistry,并解析所有方法生成MappedStatement。 - 运行时代理:通过
getMapper从工厂获取代理对象,工厂使用JDK动态代理创建,并绑定MapperProxy作为处理器。 - 调用时转换:代理对象的方法调用被
MapperProxy拦截,它委托给MapperMethod,后者根据方法签名和SQL命令类型,调用SqlSession的对应方法完成数据库操作。 - 可扩展的架构:基于动态代理和拦截器链,MyBatis的核心组件可以被插件层层包装,提供了极大的灵活性。
理解了这个原理,你就能:
- 精准调试:在遇到BindingException、插件不生效、结果映射错误时,能快速定位问题层级。
- 深度定制:有信心去编写自己的插件,或者理解MyBatis-Plus这类第三方工具的工作原理。
- 性能优化:知道哪些环节可能有缓存(如
MapperMethod缓存),哪些环节是性能热点(如反射调用),从而有针对性地进行优化。
最后,我个人的一个体会是,阅读源码不要一开始就陷入每一行代码的细节。像我们今天这样,先抓住“动态代理”这个主线,理清从getMapper到invoke的核心脉络,建立起骨架。然后再根据实际需要,去深入血肉,比如Executor的执行过程、StatementHandler的参数处理、ResultSetHandler的结果映射等。这样分层分模块地理解,效率会高很多,也更容易建立起对框架整体的掌控感。