Java-23 深入浅出 MyBatis - 手写ORM框架4 框架优化 SqlSession DefaultSqlSession

0 阅读7分钟

手写 MyBatis 框架:动态代理让 Mapper 接口告别手写实现类

TL;DR

  • 场景:自研持久层框架的 DAO 层仍有重复代码与硬编码 statementId,调用方式不像 MyBatis。
  • 结论:在 SqlSession 增加 getMapper 方法,通过 JDK 动态代理为 Mapper 接口生成代理对象,根据方法签名自动拼装 statementId 并分发到 selectList / selectOne
  • 产出:可复用的 getMapper 动态代理实现 + 完整测试调用样例 + 错误速查卡。

Java-23 深入浅出 MyBatis - 手写ORM框架4 框架优化 SqlSession DefaultSqlSession

版本矩阵

功能状态说明
SqlSession.getMapper(Class<?>) 接口定义✅ 已验证MyBatis 3.x 官方 org.apache.ibatis.session.SqlSession 标准方法,2025 年文档可查
JDK Proxy.newProxyInstance 动态代理✅ 已验证基于 java.lang.reflect.Proxy,Java 8+ 可用,2026 年仍是默认实现方式
statementId = 类全限定名.方法名 拼装规则✅ 已验证与 MyBatis 3.x MapperProxy 的命名空间解析规则一致
ParameterizedType 判断返回 List<T>✅ 已验证method.getGenericReturnType() 是 JDK 反射标准 API
Object 方法透传(toString / equals / hashCode✅ 已验证官方 MapperProxy.invoke 同样做此判断以避免误派发
CGLIB 代理 Mapper⚠️ 不适用JDK 代理要求接口,CGLIB 仅在无接口场景下由 MyBatis 选择使用

框架优化

前面我们已经手写了一个简单的持久层框架,解决了 JDBC 原生开发中的一些重复问题,比如连接获取、SQL 执行、结果封装等。

但是目前 DAO 层仍然存在两个明显问题:

  • DAO 实现类中仍然有重复代码,例如创建 SqlSession、调用查询方法等流程。
  • DAO 实现类中存在硬编码,例如调用 SqlSession 方法时,需要手动传入 statementId

本篇主要解决这两个问题:通过动态代理生成 Mapper 接口的代理对象,让调用方式更接近 MyBatis。

SqlSession

解决思路是:在 SqlSession 中增加 getMapper 方法,通过代理模式为 Mapper 接口创建代理对象。

修改 SqlSession 接口,增加如下方法:

<T> T getMapper(Class<?> mapperClass);

修改完成后,对应的截图如下:

SqlSession 接口新增 getMapper 方法

DefaultSqlSession

接下来在 DefaultSqlSession 中实现 getMapper 方法:

@Override
public <T> T getMapper(Class<?> mapperClass) {
    Object proxyInstance = Proxy.newProxyInstance(DefaultSqlSession.class.getClassLoader(), new Class[]{mapperClass}, new InvocationHandler() {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            String methodName = method.getName();
            if (method.getDeclaringClass() == Object.class) {
                return method.invoke(this, args);
            }
            String className = method.getDeclaringClass().getName();
            String statementId = className + "." + methodName;
            Type genericReturnType = method.getGenericReturnType();
            if(genericReturnType instanceof ParameterizedType){
                List<Object> objects = selectList(statementId, args);
                return objects;
            }
            return selectOne(statementId,args);

        }
    });
    return (T) proxyInstance;
}

对应的截图如下所示:

DefaultSqlSession 中 getMapper 的动态代理实现

这个方法的核心作用是:根据传入的 Mapper 接口类型,动态生成一个代理对象。以后我们就不需要手写 Mapper 的实现类了。

几个关键点如下:

  • @Override:表示该方法重写了接口中的方法。
  • <T> T:表示这是一个泛型方法,返回值类型由调用方决定。
  • getMapper(Class<?> mapperClass):接收一个 Mapper 接口的 Class 对象,用于生成对应的代理对象。

动态代理对象的创建代码如下:

Object proxyInstance = Proxy.newProxyInstance(
    DefaultSqlSession.class.getClassLoader(),
    new Class[]{mapperClass},
    new InvocationHandler() { ... }
);

各个参数的含义如下:

  • Proxy.newProxyInstance:创建 JDK 动态代理对象。
  • DefaultSqlSession.class.getClassLoader():指定类加载器。
  • new Class[]{mapperClass}:指定代理对象需要实现的接口。
  • new InvocationHandler():定义代理对象调用方法时的处理逻辑。

动态代理逻辑

代理对象调用 Mapper 接口中的方法时,都会进入 invoke 方法:

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { ... }

参数含义如下:

  • proxy:当前代理对象。
  • method:当前被调用的方法。
  • args:调用方法时传入的参数。

也就是说,当我们执行:

userInfoMapper.selectOne(userInfo);

实际并不会进入某个手写的实现类,而是进入动态代理中的 invoke 方法。

方法调用逻辑

首先获取当前调用的方法名,并处理 Object 类中的方法:

String methodName = method.getName();
if (method.getDeclaringClass() == Object.class) {
    return method.invoke(this, args);
}

这里的判断是为了处理 toString()equals()hashCode() 等方法。

如果不做这个判断,代理对象打印、比较时也会被当成普通 SQL 方法处理,容易出现不符合预期的问题。

SQL 语句标识符

接下来生成 statementId

String className = method.getDeclaringClass().getName();
String statementId = className + "." + methodName;

这里的规则是:

Mapper接口全限定名.方法名

例如 Mapper 接口是:

icu.wzk.dao.UserInfoMapper

调用的方法是:

selectOne

那么最终生成的 statementId 就是:

icu.wzk.dao.UserInfoMapper.selectOne

这样就可以和配置文件中的 SQL 语句进行匹配,避免在 DAO 实现类中手动写死 statementId

方法返回类型判断

最后根据方法返回值类型,决定调用 selectList 还是 selectOne

Type genericReturnType = method.getGenericReturnType();
if (genericReturnType instanceof ParameterizedType) {
    List<Object> objects = selectList(statementId, args);
    return objects;
}

这里通过 method.getGenericReturnType() 获取方法的返回值类型。

如果返回值是参数化类型,例如:

List<UserInfo>

那么它属于 ParameterizedType,此时调用 selectList

如果不是集合类型,则默认调用:

selectOne(statementId, args);

所以这段逻辑可以简单理解为:

  • Mapper 方法返回 List<T>:执行 selectList
  • Mapper 方法返回普通对象:执行 selectOne

通过这一步,Mapper 接口方法就和底层 SQL 执行逻辑关联起来了。

测试方法

下面编写一个测试方法,通过 SqlSessionFactory 创建 SqlSession,再通过 getMapper 获取 Mapper 代理对象:

package icu.wzk.test;

import icu.wzk.bean.Resources;
import icu.wzk.bean.SqlSession;
import icu.wzk.bean.SqlSessionFactory;
import icu.wzk.bean.SqlSessionFactoryBuilder;
import icu.wzk.dao.UserInfoMapper;
import icu.wzk.model.UserInfo;

import java.io.InputStream;


public class Test02 {

    public static void main(String[] args) throws Exception {
        InputStream inputStream = Resources.getResourceAsStream("sqlMapConfig.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        UserInfo userInfo = new UserInfo();
        userInfo.setUsername("wzk");
        UserInfoMapper userInfoMapper = sqlSession.getMapper(UserInfoMapper.class);
        System.out.println("userInfoMapper: " + userInfoMapper);
        System.out.println(userInfoMapper.selectOne(userInfo));
    }

}

测试流程如下:

  1. 读取 sqlMapConfig.xml 配置文件。
  2. 构建 SqlSessionFactory
  3. 通过 openSession() 获取 SqlSession
  4. 调用 getMapper(UserInfoMapper.class) 获取 Mapper 代理对象。
  5. 调用 Mapper 接口方法执行查询。

对应的截图如下所示:

Test02 测试方法源码

运行结果

执行之后,控制台输出结果如下:

log4j:WARN No appenders could be found for logger (com.mchange.v2.log.MLog).
log4j:WARN Please initialize the log4j system properly.
log4j:WARN See http://logging.apache.org/log4j/1.2/faq.html#noconfig for more info.
userInfoMapper: icu.wzk.bean.DefaultSqlSession$1@61dc03ce
SimpleExecutor getBoundSql: SELECT * FROM user_info WHERE username=?
UserInfo(id=1, username=wzk, password=<PASSWORD>, age=18)

对应的截图如下所示:

Test02 控制台运行输出

从运行结果可以看到,我们已经不需要手写 UserInfoMapper 的实现类,也不需要在 DAO 中手动拼接 statementId

现在的调用方式变成了:

UserInfoMapper userInfoMapper = sqlSession.getMapper(UserInfoMapper.class);
UserInfo userInfo = userInfoMapper.selectOne(queryParam);

这一步完成后,框架的使用方式已经更接近 MyBatis:

  • 开发者只需要定义 Mapper 接口。
  • 框架负责生成代理对象。
  • 代理对象根据接口名和方法名生成 statementId
  • 底层继续复用已有的 selectOneselectList 查询逻辑。

这样就减少了 DAO 层的重复代码,也消除了手写 statementId 带来的硬编码问题。


错误速查卡

症状根因定位修复
打印 userInfoMapper 时也走了 SQL 查询路径没有在 invoke 中判断 method.getDeclaringClass() == Object.classtoString 被当作 Mapper 方法DefaultSqlSession.getMapperinvoke 逻辑在拼装 statementId 之前先做 Object 方法透传
控制台报 statement id not found: xxxstatementId 没有使用 类全限定名.方法名 规则,配置文件中 namespace 或 id 不匹配检查 XML 的 namespaceid,对比运行时拼接值保持 method.getDeclaringClass().getName() + "." + method.getName() 规则,XML 同步
返回 List<T> 时只返回了第一条数据在分发逻辑里一律调用了 selectOne,没有用 ParameterizedType 判断invokegenericReturnType instanceof ParameterizedType 分支method.getGenericReturnType() 区分列表与单对象
getMapper 调用报 ClassCastExceptionProxy.newProxyInstance 返回 Object,调用方未做泛型强转,或接口未传入return (T) proxyInstance;new Class[]{mapperClass}确保传入的是接口 Class,返回处做强转
log4j 警告 No appenders could be found没有 log4j.propertieslog4j.xml运行时启动日志增加 log4j 配置或显式 BasicConfigurator.configure(),与本框架功能无关可忽略
同一个 Mapper 接口被加载多次产生多个代理没有缓存 MapperProxyFactory,每次 getMapper 都新建DefaultSqlSession.getMapper 与配置注册表引入 MapperRegistry 缓存 knownMappers,MyBatis 官方做法
接口中没有声明 throws Exception 但代理内部抛了受检异常JDK 代理不会自动包装受检异常,且 invoke 声明 throws Throwable编译错误或 UndeclaredThrowableExceptioninvoke 内部 try/catch 统一包装为运行时异常,与 MyBatis ExceptionUtil.unwrapThrowable 一致

作者:武子康的个人博客