MyBatis 如何实现"面向接口"查询(Mapper Interface 的底层原理)
你写的是
UserMapper.selectById(1),但你没写实现类。MyBatis 做的事是:启动时注册 Mapper 接口 → 运行时用动态代理生成实现 → 方法调用时定位到 MappedStatement → Executor 执行 SQL → 结果映射返回。
下面把这条链路拆开讲清楚。
1. "面向接口"到底指什么?
在 MyBatis 里,"面向接口"通常就是:
- 你只定义一个 Mapper 接口
- 不写实现类
- 直接注入/获取这个接口,然后调用它的方法完成查询
例子:
java
public interface UserMapper {
User selectById(Long id);
List<User> listByStatus(Integer status);
}
调用方:
java
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User u = mapper.selectById(1L);
这玩意之所以能跑,是因为 mapper 其实是 动态代理对象。
2. MyBatis 面向接口查询的整体链路(从调用到 SQL)
一次 mapper.selectById(1L),大致经历:
SqlSession.getMapper(UserMapper.class)MapperRegistry找到这个接口对应的MapperProxyFactoryMapperProxyFactory创建 JDK 动态代理(实现 UserMapper)- 调用
selectById进入InvocationHandler(MapperProxy) MapperProxy把方法包装成MapperMethod(缓存)MapperMethod根据方法签名定位到MappedStatement- 通过
SqlSession.selectOne / selectList进入Executor.query StatementHandler生成 PreparedStatement + 绑定参数- JDBC 执行 SQL
ResultSetHandler做结果映射,返回 Java 对象
一句话:接口只是"门面",真正干活的是动态代理 + MappedStatement + Executor。
3. 关键点一:Mapper 接口是怎么"注册"的?
MyBatis 需要先知道:哪些接口是 Mapper。
3.1 原生 MyBatis:你手动注册
java
Configuration config = sqlSessionFactory.getConfiguration();
config.addMapper(UserMapper.class);
3.2 Spring Boot:@MapperScan 自动扫描注册(最常见)
java
@MapperScan("com.example.mapper")
@SpringBootApplication
public class App {}
Spring 会扫描包,把接口交给 MyBatis 注册进 MapperRegistry。
4. 关键点二:getMapper() 到底返回了什么?
SqlSession#getMapper 最终走到:
Configuration.getMapper(type, sqlSession)MapperRegistry.getMapper(type, sqlSession)MapperProxyFactory.newInstance(sqlSession)
返回的不是实现类,而是 JDK Proxy:
- 运行时生成一个实现了
UserMapper的代理类 - 每个方法调用都转发到
MapperProxy#invoke
所以:Mapper 必须是接口(JDK 动态代理只能代理接口;当然也可以用 CGLIB,但 MyBatis 默认用 JDK)。
5. 关键点三:方法是怎么定位到对应 SQL 的?
MyBatis 用 statementId 来定位 SQL:
statementId = namespace + "." + methodName
5.1 XML 写法:namespace = 接口全限定名(强约定)
xml
<mapper namespace="com.example.mapper.UserMapper">
<select id="selectById" resultType="com.example.domain.User">
select * from user where id = #{id}
</select>
</mapper>
那么这条 SQL 的 statementId 就是:
com.example.mapper.UserMapper.selectById
当你调用 UserMapper.selectById,MyBatis 就用同样的规则拼出来 statementId,去 Configuration 里拿到对应的 MappedStatement。
5.2 注解写法:SQL 直接挂在方法上
java
public interface UserMapper {
@Select("select * from user where id = #{id}")
User selectById(@Param("id") Long id);
}
注解在启动时也会被解析成 MappedStatement,同样放进 Configuration。
6. 关键点四:MapperProxy 在 invoke 里做了什么?
MapperProxy#invoke(...) 的核心动作:
- 处理
Object自带方法(toString/equals/hashCode)直接返回 - 找到/创建
MapperMethod(并缓存) - 调用
MapperMethod.execute(sqlSession, args)
MapperMethod 内部会判断这是:
- selectOne / selectList
- insert/update/delete
- 是否返回 void / int / List / Map / Cursor / Optional 等
最终落到 SqlSession 的对应方法执行。
7. 参数是怎么绑定的?(为什么 @Param 有时必须写)
你传入 selectById(1L) 这个 1L,MyBatis 需要把它映射成 #{} 里能用的参数名。
规则大概是:
- 单参数(且不是 Map)时:一般可以直接用
#{param1}/#{value}(不同版本细节略有差异) - 多参数时:默认会给你命名成
param1,param2... - 你加了
@Param("id"),就能用#{id},可读性更强,也更稳
例子:
java
User select(@Param("id") Long id, @Param("status") Integer status);
SQL:
sql
where id = #{id} and status = #{status}
8. 结果是怎么映射回对象的?
查询执行完返回 ResultSet 后:
ResultSetHandler根据resultType/resultMap- 读取列名/别名
- 用反射或 setter 填充 Java 对象
- 返回最终结果(User / List 等)
常见坑:
- 数据库列
user_namevs Java 字段userName- 你可以开
mapUnderscoreToCamelCase=true - 或者 SQL 用别名:
select user_name as userName ...
- 你可以开
9. 为什么这种设计很"面向接口"?
因为它把关注点分开了:
- 业务只依赖接口:
UserMapper - SQL 定义在 XML/注解:可替换、可维护
- 实现由框架运行时生成:不用写模板代码
- 执行与映射由 MyBatis 统一处理:减少重复
本质上:动态代理 + 配置驱动,让"接口=查询能力"成立。
10. 最常见的 6 个问题(排查方向)
-
找不到 statement
- 报:
Invalid bound statement (not found) - 检查:XML namespace 是否是接口全名?id 是否等于方法名?XML 是否被加载?
- 报:
-
Mapper 没注册
- 检查:
@MapperScan包路径对不对?接口上有@Mapper吗?
- 检查:
-
参数取不到
- 多参数建议都加
@Param - 或检查
#{}名称是否匹配
- 多参数建议都加
-
结果映射为空/字段没填
- 检查列名 vs 字段名
- 开启驼峰映射或加别名
-
同名方法重载
- XML 的 id 只能对应一个 statement
- Mapper 方法重载很容易把你搞崩(尽量别重载)
-
Spring 注入 Mapper 失败
- 看是否依赖了
mybatis-spring-boot-starter - 包扫描、配置类、启动类位置是否正确
- 看是否依赖了
11. 一句话总结
MyBatis 的"面向接口查询"就是:
启动时把接口方法和 SQL 绑定成 MappedStatement;运行时用动态代理生成接口实现;方法调用时通过 statementId 找到 SQL 并执行。