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

#Java#面试八股

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

结论先行:Mapper 接口的「实现类」是运行期由 JDK 动态代理生成的 MapperProxy。SqlSession.getMapper() 委托 MapperRegistry 拿到 MapperProxyFactory,用接口的 ClassLoader 创建代理;方法调用被拦截后,按「接口全限定名 + 方法名」拼出 statementId,再走 SqlSession 的正常执行链。

1. 代理是怎么产生的

sqlSession.getMapper(UserMapper.class)
   │
   ▼ Configuration.getMapper()
MapperRegistry.getMapper()
   │  knownMappers: Map<Class<?>, MapperProxyFactory<?>>
   ▼
MapperProxyFactory.newInstance(sqlSession)
   │  Proxy.newProxyInstance(interface.getClassLoader(),
   │                         new Class[]{UserMapper.class},
   │                         new MapperProxy<>(sqlSession, mapperInterface, methodCache))
   ▼
返回接口代理对象

关键类只有三个:

类职责
MapperRegistry注册表,保存「接口 → 代理工厂」的映射
MapperProxyFactory用反射创建代理实例,缓存 Method → MapperMethod
MapperProxyInvocationHandler 实现,负责把方法调用翻译成 SQL 执行

2. 代理的 invoke 做了什么

// MapperProxy.invoke 的简化逻辑
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, args);            // toString/equals/hashCode 走原逻辑
    }
    // ① 缓存 MapperMethod,避免每次反射解析
    MapperMethod mapperMethod = cachedMapperMethod(method);
    // ② 执行:内部按 SQL 命令类型分派
    return mapperMethod.execute(sqlSession, args);
}

MapperMethod.execute 按 SqlCommandType 分派:

命令类型调用返回值处理
SELECTsqlSession.selectList/selectOne单值、Optional、Map、多行
INSERTsqlSession.insert影响行数,useGeneratedKeys 时回填主键
UPDATEsqlSession.update影响行数
DELETEsqlSession.delete影响行数
FLUSHsqlSession.flushStatements—

3. statementId 怎么对上 XML

MapperMethod 里维护 SqlCommand:

// 拼接规则:接口全限定名 + "." + 方法名
this.name = mapperInterface.getName() + "." + method.getName();

所以下面两者必须严格一致:

package com.example.mapper;
public interface UserMapper {
    User selectById(@Param("id") Long id);
}
<mapper namespace="com.example.mapper.UserMapper">
    <select id="selectById" resultType="com.example.User">
        SELECT id, name FROM t_user WHERE id = #{id}
    </select>
</mapper>

用 @Select 注解时不需要 XML,MapperAnnotationBuilder 在启动时把注解解析成 MappedStatement,namespace 与 id 同样按上述规则生成。

4. 一个手写的等价实现

理解代理最直接的方式是自己用 JDK 动态代理写一遍:

public class SimpleMapperProxy implements InvocationHandler {

    private final SqlSession sqlSession;

    public SimpleMapperProxy(SqlSession sqlSession) {
        this.sqlSession = sqlSession;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String statementId = method.getDeclaringClass().getName() + "." + method.getName();
        Object param = args == null ? null : (args.length == 1 ? args[0] : toParamMap(method, args));
        if (method.getReturnType().isAssignableFrom(List.class)) {
            return sqlSession.selectList(statementId, param);
        }
        return sqlSession.selectOne(statementId, param);
    }
}

// 使用
UserMapper mapper = (UserMapper) Proxy.newProxyInstance(
        UserMapper.class.getClassLoader(),
        new Class<?>[]{UserMapper.class},
        new SimpleMapperProxy(sqlSession));

这几十行就是 MyBatis Mapper 机制的核心,剩下的是参数解析、注解、缓存等工程细节。

5. 为什么不支持方法重载

statementId 只用「接口名 + 方法名」,不带参数签名。两个重载方法会映射到同一个 MappedStatement,参数语义无法区分,所以 MyBatis 明确不支持 Mapper 方法重载(启动时不会报错,运行时行为不可预期)。

6. 常见追问

追问一:Mapper 接口能写 default 方法吗? 能。MyBatis 3.4.2 起 MapperProxy 会识别 method.isDefault(),直接反射调用接口的默认实现,不进入 SQL 执行链;更早的版本会抛 BindingException。所以 default 方法里可以组合多个 Mapper 方法。

追问二:代理对象能直接 toString() 吗? 能。MapperProxy 对 Object 声明的方法直接放行,返回代理描述,不会去执行 SQL。

追问三:getMapper 每次返回新对象吗? 是。MapperProxyFactory 内部会缓存 MapperMethod,但代理实例每次新建。它不持有状态(只持有 SqlSession),所以新建开销很小。

追问四:Spring 集成后是谁调用的 getMapper? MapperFactoryBean 在启动时调用 sqlSessionTemplate.getMapper() 生成代理,注册进 IoC 容器。@Autowired 注入的就是这个代理对象。

追问五:为什么注解和 XML 可以混用? 两者最终都变成 Configuration 里的 MappedStatement,只是来源不同。MapperAnnotationBuilder 先加载注解,XMLMapperBuilder 再加载 XML;同名 id 会冲突并抛异常。

小结

Mapper 接口不需要实现类,是因为 MyBatis 用接口本身作为「SQL 的坐标」:接口名是 namespace,方法名是 statement id,参数是 SQL 参数,返回值是映射结果。MapperProxy 只做了一件事——把这个坐标翻译成 SqlSession 调用。

笔记加载中…