Mybatis15-Mapper接口的代理+MappedStatement
一、Mapper接口的代理对象的意义
第一步:没有Mapper接口时,我们怎么写代码?
在MyBatis早期(或者说如果你不用Mapper接口),你的代码长这样:
// 1. 拿到SqlSession SqlSession session = sqlSessionFactory.openSession(); // 2. 直接调用CRUD方法 // 参数1:SQL的ID(字符串),参数2:传入的参数 User user = session.selectOne("com.example.mapper.UserMapper.selectById", 1);这段代码能跑,但有几个很严重的设计缺陷:
弊端1:字符串硬编码,编译器不检查
"com.example.mapper.UserMapper.selectById"是一个字符串。如果你手滑写错了,比如把selectById写成了selectByID,编译时完全不会报错,只有运行到这行代码时才会抛出异常。
弊端2:参数类型不安全
selectOne的第二个参数是Object类型。你可以传一个String进去,也可以传一个Date进去,编译器都不会阻止你。但如果XML里期望的是int,运行时就可能类型转换失败。
弊端3:返回类型需要强制转换
selectOne返回的是Object,你需要自己强转成User。如果XML里配置的返回类型和你要转的类型不匹配,又是运行时才能发现。
弊端4:IDE无法提供有效支持
因为一切都是字符串和Object,你的IDE无法做方法跳转、参数提示、重构重命名。你想改个SQL的ID,全局搜索替换很容易漏改或改错。
第二步:于是,Mapper接口被设计出来了
为了解决上面的问题,MyBatis引入了Mapper接口:
public interface UserMapper { User selectById(int id); List<User> selectAll(); int insertUser(User user); }这个接口定义了方法名、参数类型、返回值类型。
但这里立刻出现了一个关键问题:
接口只有定义,没有实现。
UserMapper是一个接口,你不能new UserMapper()。
那谁来执行真正的SQL逻辑呢?
第三步:如果让你写实现类,问题又回来了
假设MyBatis要求你自己写实现类:
public class UserMapperImpl implements UserMapper { private SqlSession sqlSession; public UserMapperImpl(SqlSession sqlSession) { this.sqlSession = sqlSession; } @Override public User selectById(int id) { // 绕了一圈,又回到了字符串调用! return sqlSession.selectOne( "com.example.mapper.UserMapper.selectById", id); } @Override public List<User> selectAll() { return sqlSession.selectList( "com.example.mapper.UserMapper.selectAll"); } // ... 每个方法都如此 }你发现了吗?如果让你手写实现类,之前所有弊端一个都没解决——你还是在写字符串、还是在做类型转换、代码还是臃肿。
而且每增加一个Mapper接口,你就要多写一个实现类,全是样板代码(Boilerplate)。
所以MyBatis的设计者想:既然每个实现类的逻辑都是固定的——根据方法名找到SQL,执行,返回结果——那为什么不交给框架自动生成呢?
第四步:代理对象登场——框架帮你"实现"接口
这就是sqlSession.getMapper(UserMapper.class)做的事情。
它不会返回null,也不会返回一个你手写的UserMapperImpl。它返回的是一个代理对象(Proxy Object)。
这个代理对象在运行时被动态创建,它实现了UserMapper接口,所以你完全可以把它当成UserMapper来用:
UserMapper mapper = sqlSession.getMapper(UserMapper.class); // 这行代码能编译通过,因为返回的对象确实实现了UserMapper接口 User user = mapper.selectById(1);核心问题:代理对象里并没有真正的业务逻辑,那selectById(1)是怎么执行的?
第五步:JDK动态代理 + MapperProxy 的工作机制
MyBatis使用的是JDK动态代理。要理解它,你需要知道三个角色:
1. 接口(UserMapper)
定义了"长什么样"——有哪些方法,参数和返回值是什么。
2. 代理对象(运行时动态生成的类)
它实现了UserMapper接口,所以你可以把它赋值给UserMapper类型的变量。
3. 调用处理器(MapperProxy)
这是真正干活的人。它实现了InvocationHandler接口。
当你调用:
mapper.selectById(1);实际上,JVM并没有执行某个类里写好的selectById方法,而是把这个调用拦截下来,转交给了MapperProxy的invoke方法。
MapperProxy.invoke里大概做了这些事:
获取方法信息:知道你调用的是
selectById,参数是1解析SQL定位:根据接口全限定名 + 方法名,找到XML或注解中对应的SQL语句
调用SqlSession:最终还是会走到
sqlSession.selectOne(...),但这个过程对你是透明的结果映射:把查询结果转换成
User类型返回
// 伪代码,帮你理解流程 public Object invoke(Object proxy, Method method, Object[] args) { // 1. 拿到方法名:selectById String methodName = method.getName(); // 2. 拿到接口名:com.example.mapper.UserMapper String interfaceName = method.getDeclaringClass().getName(); // 3. 组合成SQL ID:com.example.mapper.UserMapper.selectById String statementId = interfaceName + "." + methodName; // 4. 根据返回类型决定调用selectOne还是selectList if (method.getReturnType() == List.class) { return sqlSession.selectList(statementId, args[0]); } else { return sqlSession.selectOne(statementId, args[0]); } }第六步:为什么必须用代理?不用行不行?
不用代理,你有两个选择,但都有致命缺陷:
| 方案 | 缺陷 |
|---|---|
| 直接调用SqlSession | 字符串硬编码、无类型安全、无IDE支持 |
| 手写实现类 | 样板代码爆炸、维护困难、没有解决本质问题 |
代理模式是唯一的出路,因为它同时解决了:
类型安全:你操作的是
UserMapper接口,方法参数和返回值都是强类型的,编译器会检查。无字符串硬编码:方法名就是SQL ID,通过反射自动获取,写错方法名编译期就能发现。
零样板代码:你不需要写实现类,框架动态生成。
IDE友好:你可以Ctrl+点击跳转,可以安全重构重命名。
第七步:为什么是JDK动态代理?
JDK动态代理有一个硬性要求:被代理的必须是接口。
MyBatis的Mapper天然就是接口,所以完美契合。
它的本质是java.lang.reflect.Proxy.newProxyInstance(...)在运行时生成一个类,大概长这样(伪代码):
// 这是JVM在内存中动态生成的类,你看不到源码 public class $Proxy0 implements UserMapper { private InvocationHandler handler; // 这就是MapperProxy public User selectById(int id) { // 所有方法调用都转发给handler return (User) handler.invoke(this, selectById方法对象, new Object[]{id}); } }$Proxy0这个类是运行时临时生成的字节码,它实现了UserMapper,并把每个方法调用都委托给MapperProxy。
总结:逻辑链条
因为直接调用
SqlSession有字符串硬编码、类型不安全、维护困难等弊端;所以引入了
Mapper接口,利用Java的类型系统提供编译期检查;但是接口不能实例化,需要有人来实现接口里的方法;
如果让开发者手写实现类,会写大量样板代码,且本质上还是调用SqlSession,没有解决问题;
因此MyBatis使用JDK动态代理,由框架在运行时自动生成接口的实现(代理对象);
最终
MapperProxy拦截所有方法调用,自动解析方法名、找到SQL、执行并返回结果。
你拿到的mapper对象,本质上是一个由MyBatis自动生成的、会帮你转发请求到SqlSession的接口实现。你表面上在调用接口方法,实际上MyBatis在背后帮你完成了"找SQL → 传参数 → 执行 → 转结果"的一整套流程。
这就是代理的意义:让你用优雅的方式(调用接口方法),去做原本繁琐且易错的事情(操作SqlSession)。
二、MappedStatement 类的讲解
第一步:如果没有MappedStatement,执行SQL会面临什么困境?
假设MyBatis没有MappedStatement这个概念,最原始的执行方式可能是这样:
// 伪代码:直接传SQL字符串 session.execute("SELECT * FROM user WHERE id = ?", 1);但这远远不够。一条SQL要正确执行并返回正确结果,框架至少需要知道:
参数类型:
?对应的Java类型是什么?是int、String还是User对象?这决定了JDBC的setXxx方法用哪个。返回类型:查询结果要封装成
User对象,还是Map,还是List<String>?SQL命令类型:这是
SELECT还是INSERT?如果是INSERT,可能需要获取数据库自增的主键;如果是SELECT,需要返回结果集。结果映射规则:数据库字段名叫
user_name,Java属性叫userName,这个映射关系怎么告诉框架?动态SQL:这条SQL可能包含
<if>标签,需要根据参数动态拼接,不是固定的字符串。缓存配置:这条SQL的结果要不要走二级缓存?执行这条SQL前要不要清空缓存?
超时时间:这条SQL最多执行多久?
Statement类型:用普通的
Statement、预编译的PreparedStatement,还是存储过程的CallableStatement?
如果把这些信息都作为execute方法的参数,API会变成这样:
session.execute( "SELECT * FROM user WHERE id = ?", // SQL 1, // 参数 Integer.class, // 参数类型 User.class, // 返回类型 resultMap, // 结果映射规则 SqlCommandType.SELECT, // 命令类型 true, // 是否使用缓存 false, // 是否刷新缓存 5000, // 超时毫秒 StatementType.PREPARED // Statement类型 );弊端非常明显:
每次执行SQL都要传一堆参数,极易遗漏、顺序搞错
这些信息其实是固定不变的(写在XML里就不会变),但每次执行都要重复传递
动态SQL的解析逻辑无处安放,不可能每次执行都重新解析XML
第二步:一条SQL的所有信息,本质上是一个"整体"
让我们换个角度思考:你在UserMapper.xml里写的每一个<select>、<insert>、<update>、<delete>标签,其实都在描述同一件事——如何执行一条特定的SQL。
比如:
<select id="selectById" parameterType="int" resultType="User" useCache="true" timeout="5000"> SELECT * FROM user WHERE id = #{id} </select>这个标签里包含了:
id:
selectById(唯一标识)SQL文本:
SELECT * FROM user WHERE id = ?参数类型:
int返回类型:
User缓存配置:
useCache="true"超时配置:
timeout="5000"
这些信息是内聚的——它们只和"根据ID查询用户"这一条SQL相关。既然它们是内聚的,代码设计上就应该把它们封装成一个对象。
这就是MappedStatement的设计来源:
一个
MappedStatement对象 = 一条SQL语句 + 执行这条SQL所需的全部元信息
第三步:MappedStatement里面到底装了什么?
MappedStatement是一个普通的Java类(虽然名字叫"Statement",但它不是JDBC的Statement),核心属性如下:
public final class MappedStatement { private String id; // 唯一标识,格式:namespace + "." + id private SqlSource sqlSource; // SQL源(封装了动态SQL解析逻辑) private SqlCommandType sqlCommandType; // SELECT / INSERT / UPDATE / DELETE private Class<?> parameterType; // 参数Java类型 private List<ResultMap> resultMaps; // 结果映射配置 private boolean flushCacheRequired; // 执行前是否清空二级缓存 private boolean useCache; // 是否使用二级缓存 private Integer timeout; // 超时时间 private StatementType statementType; // STATEMENT / PREPARED / CALLABLE // ... 还有其他属性 }注意这里有一个关键设计:sqlSource的类型不是String,而是SqlSource。
因为MyBatis支持动态SQL(<if>、<foreach>等),SQL文本不是固定的。
SqlSource的职责是:根据运行时传入的参数,生成最终可执行的SQL(生成一个叫BoundSql的对象)。
所以MappedStatement不仅封装了静态配置,还封装了动态SQL的解析逻辑。
第四步:MappedStatement解决了哪些具体问题?
1. 信息聚合,告别"散弹枪式"传参
以前执行SQL需要传10个分散的参数,现在只需要传一个MappedStatement对象。所有和这条SQL相关的配置都在它内部,调用方和执行方都轻松。
2. 启动时解析,运行时复用
MappedStatement在MyBatis初始化阶段(解析XML或扫描注解时)就被创建好了,然后存入内存。运行时执行SQL时直接取出来用,不需要重复解析XML,性能上完全可控。
3. 统一执行器的调度入口
SqlSession底层委托给Executor执行。Executor的所有执行方法都接收MappedStatement作为参数:
// Executor接口定义 <E> List<E> query(MappedStatement ms, Object parameter, ...); int update(MappedStatement ms, Object parameter);Executor拿到MappedStatement后,就能独立完成全部操作:
调用
ms.getSqlSource().getBoundSql(parameter)→ 得到最终SQL查看
ms.getSqlCommandType()→ 知道是查询还是更新查看
ms.getResultMaps()→ 知道怎么把ResultSet转成Java对象查看
ms.getStatementType()→ 知道创建哪种JDBC Statement
MappedStatement让执行器具备了"自解释"的能力——执行器不需要外部再告诉它任何额外信息。
4. 动态SQL的天然载体
如果没有MappedStatement,动态SQL的逻辑放在哪里?
MappedStatement内部的SqlSource完美解决了这个问题。它把"根据参数生成SQL"的逻辑封装了起来,对外只暴露一个getBoundSql(parameter)方法。
第五步:MappedStatement在MyBatis中的完整生命周期
阶段1:解析阶段(应用启动时)
当你写了一个Mapper XML:
<mapper namespace="com.example.mapper.UserMapper"> <select id="selectById" resultType="User"> SELECT * FROM user WHERE id = #{id} </select> </mapper>MyBatis的XMLMapperBuilder会解析这个<select>标签:
// 伪代码 MappedStatement.Builder builder = new MappedStatement.Builder( configuration, // 全局配置 "com.example.mapper.UserMapper.selectById", // id sqlSource, // 解析出的SQL源 SqlCommandType.SELECT // 命令类型 ); builder.resultType(User.class); // ... 设置其他属性 MappedStatement ms = builder.build(); configuration.addMappedStatement(ms); // 注册到Configuration阶段2:存储阶段(在Configuration中)
public class Configuration { // key: "namespace.id" value: MappedStatement protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>(); }所有解析好的MappedStatement都存在这个Map里。这就是为什么SQL ID不能重复——它是Map的key。
阶段3:执行阶段(运行时)
当你调用:
User user = mapper.selectById(1);内部的调用链是这样的:
MapperProxy.invoke()拦截到selectById方法调用组合出ID:
"com.example.mapper.UserMapper.selectById"从
Configuration.mappedStatements中根据ID取出对应的MappedStatement调用
sqlSession.selectOne(mappedStatement, 1)——注意这里传的是对象,不是字符串Executor从MappedStatement中提取所有信息,完成JDBC操作
第六步:为什么不能直接用字符串ID,非要包装成对象?
你可能会问:既然最终也是根据"namespace.id"找到SQL,为什么不能直接传字符串ID,让框架每次执行时去XML里查?
答案是三个层面的问题:
| 层面 | 原因 |
|---|---|
| 性能 | 每次执行都解析XML是不可接受的。MappedStatement在启动时解析一次,后续内存中直接复用。 |
| 信息完整性 | 字符串ID只能定位到SQL文本,但无法携带参数类型、返回类型、缓存配置等元信息。 |
| 动态SQL | 动态SQL需要根据参数实时生成最终SQL,这个逻辑封装在MappedStatement持有的SqlSource中,不是简单的字符串查找。 |
总结:逻辑链条
因为执行一条SQL需要大量元信息(参数类型、返回类型、SQL类型、缓存配置、超时设置等),如果分散传递会导致API臃肿、调用混乱、极易出错;
所以MyBatis设计了
MappedStatement,把一条SQL的所有执行元信息封装成一个对象;因为这些信息是固定不变的,可以在应用启动时解析XML/注解一次性准备好;
所以
MappedStatement被创建后存入Configuration的Map中,以"namespace.id"为键,运行时直接复用;因为执行时只需要一个
MappedStatement对象,就能拿到SQL文本、参数映射、结果映射、缓存策略等全部信息;所以
SqlSession和Executor的执行方法都接收MappedStatement作为核心参数,内部从中提取所有配置,完成完整的数据库操作。
MappedStatement就是MyBatis中一条SQL的完整执行档案。它让SQL的执行从"临时拼凑参数"变成了"有备而来、一应俱全"。
