为什么 MyBatis 的 Mapper 接口不需要实现类?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 动态代理理解:面试官不仅仅是想知道 "不需要实现类" 这个结论,更是想考察你是否理解 JDK 动态代理的核心机制——Proxy + InvocationHandler

  2. MyBatis 映射原理:看你能否说清楚 Mapper 接口方法是如何与 XML 中的 SQL 关联起来的,这个 "桥梁" 是怎么架起来的。

  3. 源码级深度:高级面试中,面试官会期望你说出 MapperProxyMapperMethodMappedStatement 这些关键类的作用。

核心答案

因为 MyBatis 使用 JDK 动态代理,在运行时自动为 Mapper 接口生成代理对象,开发者的方法调用会被 MapperProxy 拦截,根据接口全限定名 + 方法名找到对应的 MappedStatement(SQL 元数据),然后委托给 SqlSession 执行。

Mapper 接口与 SQL 的关联依赖于一个核心约定:接口的全限定名 + 方法名 = Mapper XML 中的 namespace + SQL 标签的 id。MyBatis 启动时解析 XML 中每个 SQL 标签,生成 MappedStatement 对象,以 namespace.id 为 key 存入 ConfigurationmappedStatements 集合中。代理对象拦截到方法调用时,就能通过这个 key 找到对应的 SQL。

深度解析

一、完整调用链路

整个调用链路可以分为两个阶段:

  • 代理创建阶段(步骤 ①~④):调用 sqlSession.getMapper(UserMapper.class) 时,MyBatis 从 Configuration 中的 MapperRegistry 找到对应的 MapperProxyFactory,通过 JDK 动态代理创建一个实现了 UserMapper 接口的代理对象。这个代理对象持有一个 MapperProxy(实现了 InvocationHandler)。

  • 方法调用阶段(步骤 ⑤~⑨):当开发者调用 mapper.selectById(1L) 时,代理对象将调用转发给 MapperProxy.invoke()invoke() 方法将 Java 方法封装为 MapperMethod 对象(会缓存),MapperMethod 根据方法签名判断 SQL 类型,构造出 statementId(全限定名 + 方法名),然后委托给 SqlSession 的对应方法执行。

二、源码关键类

类名职责
MapperProxy实现 InvocationHandler,拦截 Mapper 接口的方法调用
MapperProxyFactory工厂类,通过 JDK 动态代理创建 Mapper 代理对象
MapperMethod封装Mapper 接口方法的信息(SQL 类型、参数、返回值),负责路由到 SqlSession
MapperRegistry注册中心,维护 Class<T>MapperProxyFactory<T> 的映射
MappedStatement一条 SQL 的完整元数据(SQL 文本、参数映射、结果映射等)

三、核心源码解析

MapperProxy——代理对象的核心

// MapperProxy 实现了 InvocationHandler
public class MapperProxy<T> implements InvocationHandler {

    private final SqlSession sqlSession;
    private final Class<T> mapperInterface;
    // 方法缓存:避免每次调用都创建 MapperMethod
    private final Map<Method, MapperMethod> methodCache;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 如果调用的是 Object 的方法(如 toString、hashCode),直接执行
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, args);
        }

        // 将 Method 封装为 MapperMethod(带缓存)
        MapperMethod mapperMethod = cachedMapperMethod(method);

        // 执行:路由到 SqlSession 的对应方法
        return mapperMethod.execute(sqlSession, args);
    }
}

MapperProxyinvoke() 方法是整个代理机制的核心入口:

  • 首先判断方法是否来自 Object 类(如 toString()hashCode()),如果是就直接执行,不走代理逻辑。
  • 然后将 java.lang.reflect.Method 封装为 MapperMethod 对象,并缓存到 methodCache 中。MapperMethod 解析了方法的返回类型、参数注解(如 @Param)等信息。
  • 最后调用 mapperMethod.execute(sqlSession, args),根据 SQL 类型路由到 SqlSession 的对应方法。

MapperMethod——SQL 类型的路由器

public class MapperMethod {

    private final SqlCommand command;    // SQL 类型 + statementId
    private final MethodSignature method; // 方法签名信息

    public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
        // 构造 statementId = "接口全限定名.方法名"
        // 比如 "com.example.mapper.UserMapper.selectById"
        this.command = new SqlCommand(config, mapperInterface, method);
        this.method = new MethodSignature(config, method);
    }

    public Object execute(SqlSession sqlSession, Object[] args) {
        Object result;
        switch (command.getType()) {
            case SELECT:
                if (method.returnsMany()) {
                    // 返回集合 → selectList
                    result = sqlSession.selectList(command.getName(), args);
                } else {
                    // 返回单个对象 → selectOne
                    result = sqlSession.selectOne(command.getName(), args);
                }
                break;
            case INSERT:
                result = sqlSession.insert(command.getName(), args);
                break;
            case UPDATE:
                result = sqlSession.update(command.getName(), args);
                break;
            case DELETE:
                result = sqlSession.delete(command.getName(), args);
                break;
            default:
                throw new BindingException("Unknown execution method for: " + command.getName());
        }
        return result;
    }
}

MapperMethod 的核心逻辑是根据 command.getType()(SQL 类型)路由到 SqlSession 的对应方法。command.getName() 就是 statementId(全限定名 + 方法名),SqlSession 用这个 key 从 Configuration 中找到对应的 MappedStatement,然后执行 SQL。

四、接口与 XML 的关联约定

Mapper 接口能 "自动" 找到 XML 中的 SQL,靠的是一个命名约定

<!-- Mapper XML -->
<mapper namespace="com.example.mapper.UserMapper">
    <!-- id 必须和接口方法名一致 -->
    <select id="selectById" resultType="User">
        SELECT * FROM t_user WHERE id = #{id}
    </select>
</mapper>
// Mapper 接口
package com.example.mapper;

public interface UserMapper {
    // 方法名必须和 XML 中 <select> 的 id 一致
    User selectById(Long id);
}

关联规则:

  • XML 的 namespace = 接口的全限定名com.example.mapper.UserMapper
  • SQL 标签的 id = 接口的方法名selectById
  • 两者拼接后的 statementId = ConfigurationMappedStatement 的 key

如果名字对不上会怎样? 启动时不会报错(因为接口和 XML 是独立解析的),但调用方法时会抛出 BindingExceptionInvalid bound statement (not found): com.example.mapper.UserMapper.selectById

五、为什么用 JDK 动态代理而不是 CGLIB?

对比维度JDK 动态代理CGLIB
要求必须有接口不需要接口,生成子类
Mapper 接口✅ 天然是接口,完美适配❌ 接口无法被继承生成子类
性能略低(JDK 8 之后差距很小)略高
依赖JDK 内置,无额外依赖需要引入 CGLIB 库

Mapper 接口本身就是接口,JDK 动态代理是唯一选择(CGLIB 通过生成子类实现代理,接口没有实现类,无法生成子类)。这是最自然、最合适的方案。

六、常见误区

  1. 误区一:"MyBatis 帮我生成了 Mapper 的实现类(.class 文件)"
    • 不是。JDK 动态代理是在运行时内存中生成代理类($Proxy0),不会生成 .class 文件(除非开启 -Dsun.misc.ProxyGenerator.saveGeneratedFiles=true 用于调试)。代理对象是动态创建的,没有静态的实现类。
  2. 误区二:"XML 中的 SQL 必须和接口方法一一对应"
    • 是的。XML 中的每个 <select>/<insert>/<update>/<delete> 标签都必须有对应的接口方法,反之亦然。如果 XML 中有 SQL 但接口没方法,启动不报错但调用报错;如果接口有方法但 XML 没有 SQL,调用时抛 BindingException
  3. 误区三:"注解方式的 Mapper 也有 XML"
    • 不一定。使用 @Select@Insert 等注解后,SQL 直接写在接口方法上,不需要 XML。MyBatis 会解析注解生成 MappedStatement,代理机制完全相同。

面试高频追问

  1. 追问一:如果 Mapper 接口方法名和 XML 的 id 不一致会怎样?
    • 启动时不会报错(接口和 XML 独立解析)。运行时调用该方法会抛出 BindingExceptionInvalid bound statement (not found): com.example.mapper.UserMapper.xxx。排查时先检查 namespace 是否和全限定名一致,再检查方法名和 id 是否一致。
  2. 追问二:MyBatis 是在什么时候把 Mapper 接口注册到 MapperRegistry 的?
    • 在 MyBatis 初始化阶段,XMLMapperBuilder 解析 Mapper XML 时,会根据 namespace 将对应的接口注册到 MapperRegistry 中。如果使用 mybatis-config.xml 中的 <mapper class="..."/>@MapperScan(Spring),也会触发注册。
  3. 追问三:同一个 Mapper 接口能不能同时用注解和 XML?
    • 可以,但不能冲突。同一个方法不能既用 @Select 注解又在 XML 中定义同名 SQL,否则启动报错。可以一部分方法用注解,另一部分用 XML。

常见面试变体

  • 变体一:"MyBatis 的 Mapper 接口是怎么和 XML 关联的?"
  • 变体二:"说说 MyBatis 的动态代理机制?"
  • 变体三:"MyBatis 调用 Mapper 方法的完整流程是什么?"

记忆口诀

接口无实现靠代理,JDK 动态 Proxy + MapperProxy;全限定名加方法名,等于 namespace 加 SQL id;MapperMethod 路由到 SqlSession,代理拦截一切自动搞定。

总结

Mapper 接口不需要实现类,是因为 MyBatis 使用 JDK 动态代理在运行时为接口生成代理对象。MapperProxy 拦截方法调用,根据接口全限定名 + 方法名找到对应的 MappedStatement,通过 MapperMethod 路由到 SqlSession 的对应方法执行 SQL。整个关联依赖于 namespace + id 的命名约定。