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方法,而是把这个调用拦截下来,转交给了MapperProxyinvoke方法


MapperProxy.invoke里大概做了这些事:

  1. 获取方法信息:知道你调用的是selectById,参数是1

  2. 解析SQL定位:根据接口全限定名 + 方法名,找到XML或注解中对应的SQL语句

  3. 调用SqlSession最终还是会走到sqlSession.selectOne(...),但这个过程对你是透明的

  4. 结果映射:把查询结果转换成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支持
手写实现类样板代码爆炸、维护困难、没有解决本质问题

代理模式是唯一的出路,因为它同时解决了:

  1. 类型安全:你操作的是UserMapper接口,方法参数和返回值都是强类型的,编译器会检查。

  2. 无字符串硬编码:方法名就是SQL ID,通过反射自动获取,写错方法名编译期就能发现。

  3. 零样板代码:你不需要写实现类,框架动态生成。

  4. 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


总结:逻辑链条

  1. 因为直接调用SqlSession有字符串硬编码、类型不安全、维护困难等弊端;

  2. 所以引入了Mapper接口,利用Java的类型系统提供编译期检查;

  3. 但是接口不能实例化,需要有人来实现接口里的方法;

  4. 如果让开发者手写实现类,会写大量样板代码,且本质上还是调用SqlSession,没有解决问题;

  5. 因此MyBatis使用JDK动态代理,由框架在运行时自动生成接口的实现(代理对象);

  6. 最终MapperProxy拦截所有方法调用,自动解析方法名、找到SQL、执行并返回结果

你拿到的mapper对象,本质上是一个由MyBatis自动生成的、会帮你转发请求到SqlSession的接口实现。你表面上在调用接口方法,实际上MyBatis在背后帮你完成了"找SQL → 传参数 → 执行 → 转结果"的一整套流程。

这就是代理的意义:让你用优雅的方式(调用接口方法),去做原本繁琐且易错的事情(操作SqlSession)

二、MappedStatement 类的讲解

第一步:如果没有MappedStatement,执行SQL会面临什么困境?

假设MyBatis没有MappedStatement这个概念,最原始的执行方式可能是这样:

// 伪代码:直接传SQL字符串 session.execute("SELECT * FROM user WHERE id = ?", 1);

但这远远不够。一条SQL要正确执行并返回正确结果,框架至少需要知道:

  1. 参数类型?对应的Java类型是什么?是intString还是User对象?这决定了JDBC的setXxx方法用哪个。

  2. 返回类型:查询结果要封装成User对象,还是Map,还是List<String>

  3. SQL命令类型:这是SELECT还是INSERT?如果是INSERT,可能需要获取数据库自增的主键;如果是SELECT,需要返回结果集。

  4. 结果映射规则:数据库字段名叫user_name,Java属性叫userName,这个映射关系怎么告诉框架?

  5. 动态SQL:这条SQL可能包含<if>标签,需要根据参数动态拼接,不是固定的字符串。

  6. 缓存配置:这条SQL的结果要不要走二级缓存?执行这条SQL前要不要清空缓存?

  7. 超时时间:这条SQL最多执行多久?

  8. 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>

这个标签里包含了:

  • idselectById(唯一标识)

  • 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. 启动时解析,运行时复用

MappedStatementMyBatis初始化阶段(解析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);

内部的调用链是这样的:

  1. MapperProxy.invoke()拦截到selectById方法调用

  2. 组合出ID:"com.example.mapper.UserMapper.selectById"

  3. Configuration.mappedStatements根据ID取出对应的MappedStatement

  4. 调用sqlSession.selectOne(mappedStatement, 1)——注意这里传的是对象,不是字符串

  5. ExecutorMappedStatement中提取所有信息,完成JDBC操作


第六步:为什么不能直接用字符串ID,非要包装成对象?

你可能会问:既然最终也是根据"namespace.id"找到SQL,为什么不能直接传字符串ID,让框架每次执行时去XML里查?

答案是三个层面的问题:

层面原因
性能每次执行都解析XML是不可接受的。MappedStatement在启动时解析一次,后续内存中直接复用。
信息完整性字符串ID只能定位到SQL文本,但无法携带参数类型、返回类型、缓存配置等元信息。
动态SQL动态SQL需要根据参数实时生成最终SQL,这个逻辑封装在MappedStatement持有的SqlSource中,不是简单的字符串查找。

总结:逻辑链条

  1. 因为执行一条SQL需要大量元信息(参数类型、返回类型、SQL类型、缓存配置、超时设置等),如果分散传递会导致API臃肿、调用混乱、极易出错;

  2. 所以MyBatis设计了MappedStatement,把一条SQL的所有执行元信息封装成一个对象;

  3. 因为这些信息是固定不变的,可以在应用启动时解析XML/注解一次性准备好;

  4. 所以MappedStatement被创建后存入Configuration的Map中"namespace.id"为键,运行时直接复用;

  5. 因为执行时只需要一个MappedStatement对象,就能拿到SQL文本、参数映射、结果映射、缓存策略等全部信息;

  6. 所以SqlSessionExecutor的执行方法都接收MappedStatement作为核心参数,内部从中提取所有配置,完成完整的数据库操作。

MappedStatement就是MyBatis中一条SQL的完整执行档案。它让SQL的执行从"临时拼凑参数"变成了"有备而来、一应俱全"。