1.5 Mapper 接口与 XML 的绑定原理 本节摘要:Mapper 接口与 XML 的绑定靠三根缝合线:namespace 等于接口全限定名、语句 id 等于方法名、参数与返回类型由代理负责适配。运行期 JDK 动态代理生成 MapperProxy 实现接口,把方法调用翻译成 SqlSession 的执行。本节用一次 BindingException 的排错过程验证这套机制,并对比注解映射的适用边界。 第 1 章收尾的一节,也是全册地基的一节:接口没有实现类却能执行 SQL,这句话每个用过 MyBatis 的人都说过,但能讲清机制的不多。 绑定的三根缝合线 回看已经写过的两块代码: 第三根缝合线看不见:方法参数怎么进 SQL、返回结果怎么变回对象,由运行期的代理对象负责。
本节摘要:Mapper 接口与 XML 的绑定靠三根缝合线:namespace 等于接口全限定名、语句 id 等于方法名、参数与返回类型由代理负责适配。运行期 JDK 动态代理生成 MapperProxy 实现接口,把方法调用翻译成 SqlSession 的执行。本节用一次 BindingException 的排错过程验证这套机制,并对比注解映射的适用边界。
第 1 章收尾的一节,也是全册地基的一节:接口没有实现类却能执行 SQL,这句话每个用过 MyBatis 的人都说过,但能讲清机制的不多。
回看已经写过的两块代码:
// 接口:包名 com.example.mapper,接口名 UserMapper package com.example.mapper; public interface UserMapper { User findById(int id); List<User> findByStatus(String status); }
<mapper namespace="com.example.mapper.UserMapper"> <!-- 缝合线一:namespace 必须等于接口全限定名 --> <select id="findById" parameterType="int" resultType="com.example.entity.User"> SELECT id, username, email FROM users WHERE id = #{id} <!-- 缝合线二:id 必须等于方法名 --> </select> <select id="findByStatus" resultType="com.example.entity.User"> SELECT id, username, email FROM users WHERE status = #{status} </select> </mapper>
第三根缝合线看不见:方法参数怎么进 SQL、返回结果怎么变回对象,由运行期的代理对象负责。前两根线由人维护,任何一根断开,绑定就失败。
getMapper 拿到的不是你写的类,而是 JDK 动态代理在内存里生成的代理对象。把这条链路逐步展开:

用一段"手写版代理"把机制摊平——下面这段代码与真实实现相比只省略了缓存与异常包装,逻辑同构:
// 手写版 MapperProxy:揭开代理的面纱(示意实现) public class MyMapperProxy implements InvocationHandler { private final SqlSession session; private final Class<?> iface; MyMapperProxy(SqlSession session, Class<?> iface) { this.session = session; this.iface = iface; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 核心:接口全限定名 + 方法名 = XML 中的语句标识 String statement = iface.getName() + "." + method.getName(); // 按返回类型选择执行方式 if (method.getReturnType() == List.class) { return session.selectList(statement, args.length > 0 ? args[0] : null); } return session.selectOne(statement, args.length > 0 ? args[0] : null); } } // 使用 JDK 动态代理生成接口实例 UserMapper mapper = (UserMapper) Proxy.newProxyInstance( UserMapper.class.getClassLoader(), new Class[]{UserMapper.class}, new MyMapperProxy(session, UserMapper.class)); User u = mapper.findById(1); // 实际进入 invoke 方法
读这段代码能解释很多"玄学":为什么方法名拼错要到运行期才报错(拼接发生在运行期);为什么返回 List 与单个对象用同一个 XML 都行(由代理按方法签名选择 selectList 或 selectOne);为什么接口可以随便加 default 方法之外的任何签名(代理不在乎签名,只在乎名字)。
背景:新同事把 UserMapper 从 com.example.mapper 挪到 com.example.dao,改了 Java 代码但忘了动 XML。启动正常,一调用 findById 就抛异常。
症状:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.dao.UserMapper.findById
操作:报错信息里的语句全名是排查的钥匙——它就是代理拼出来的"接口全限定名 + 方法名"。拿着这个名字去注册表里找:XML 的 namespace 还是旧包名 com.example.mapper.UserMapper,两边对不上。
结果:把 namespace 改成 com.example.dao.UserMapper 后恢复。
解读:这类异常的排查顺序固定为三步——查 namespace 是否等于接口全限定名;查语句 id 是否等于方法名;查该 XML 是否真的被 mappers 配置注册(没注册的文件等于不存在)。九成的"not found"出在前两步,剩下一成是注册问题(比如 XML 没被打进包,1.2 节的资源目录坑)。
变式:方法重载在这里行不通。同一接口写 findById(int) 与 findById(long) 两个方法,代理拼出的语句全名相同,无法对应两条不同语句——MyBatis 的映射世界里,方法名就是唯一标识。
简单语句可以完全不写 XML,注解直接放在接口方法上:
public interface UserMapper { @Select("SELECT id, username, email FROM users WHERE id = #{id}") User findById(int id); @Insert("INSERT INTO users(username, email) VALUES(#{username}, #{email})") int insert(User user); }
两种风格的边界很清晰:
| 维度 | XML 映射 | 注解映射 |
|---|---|---|
| 动态 SQL | 标签完整支持 | 需要脚本标记包裹,冗长 |
| SQL 长度 | 集中管理,长语句可读 | 挤在方法签名上,长语句难维护 |
| 修改生效 | 改 XML 即可 | 改 Java 需重新编译 |
| 适用 | 生产项目主力 | 短小语句、快速原型、工具类 |
⚠️ 常见坑:注解与 XML 同时定义同一个方法(同 id 同 namespace),启动时直接冲突报错,MyBatis 不做覆盖,只做拒绝。
💡 关键直觉:注解不是"更现代的 XML",而是同一套注册表的两个入口。无论哪条路进来,最终都变成同一个 MappedStatement 对象挂在配置里——所以两种风格可以共存,但不能重叠。
第 1 章到此收官:器械认全、通道打通、绑定机制拆开。从下一章起,镜头移到手术的入口端,看参数如何装配进 SQL。