手写 MyBatis 框架:动态代理让 Mapper 接口告别手写实现类
TL;DR
- 场景:自研持久层框架的 DAO 层仍有重复代码与硬编码
statementId,调用方式不像 MyBatis。 - 结论:在
SqlSession增加getMapper方法,通过 JDK 动态代理为 Mapper 接口生成代理对象,根据方法签名自动拼装statementId并分发到selectList/selectOne。 - 产出:可复用的
getMapper动态代理实现 + 完整测试调用样例 + 错误速查卡。
版本矩阵
| 功能 | 状态 | 说明 |
|---|---|---|
SqlSession.getMapper(Class<?>)接口定义 | ✅ 已验证 | MyBatis 3.x 官方org.apache.ibatis.session.SqlSession标准方法,2025 年文档可查 |
JDKProxy.newProxyInstance动态代理 | ✅ 已验证 | 基于java.lang.reflect.Proxy,Java 8+ 可用,2026 年仍是默认实现方式 |
statementId = 类全限定名.方法名拼装规则 | ✅ 已验证 | 与 MyBatis 3.xMapperProxy的命名空间解析规则一致 |
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>TgetMapper(Class<?>mapperClass);修改完成后,对应的截图如下:
DefaultSqlSession
接下来在DefaultSqlSession中实现getMapper方法:
@Overridepublic<T>TgetMapper(Class<?>mapperClass){ObjectproxyInstance=Proxy.newProxyInstance(DefaultSqlSession.class.getClassLoader(),newClass[]{mapperClass},newInvocationHandler(){@OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{StringmethodName=method.getName();if(method.getDeclaringClass()==Object.class){returnmethod.invoke(this,args);}StringclassName=method.getDeclaringClass().getName();StringstatementId=className+"."+methodName;TypegenericReturnType=method.getGenericReturnType();if(genericReturnTypeinstanceofParameterizedType){List<Object>objects=selectList(statementId,args);returnobjects;}returnselectOne(statementId,args);}});return(T)proxyInstance;}对应的截图如下所示:
这个方法的核心作用是:根据传入的 Mapper 接口类型,动态生成一个代理对象。以后我们就不需要手写 Mapper 的实现类了。
几个关键点如下:
@Override:表示该方法重写了接口中的方法。<T> T:表示这是一个泛型方法,返回值类型由调用方决定。getMapper(Class<?> mapperClass):接收一个 Mapper 接口的Class对象,用于生成对应的代理对象。
动态代理对象的创建代码如下:
ObjectproxyInstance=Proxy.newProxyInstance(DefaultSqlSession.class.getClassLoader(),newClass[]{mapperClass},newInvocationHandler(){...});各个参数的含义如下:
Proxy.newProxyInstance:创建 JDK 动态代理对象。DefaultSqlSession.class.getClassLoader():指定类加载器。new Class[]{mapperClass}:指定代理对象需要实现的接口。new InvocationHandler():定义代理对象调用方法时的处理逻辑。
动态代理逻辑
代理对象调用 Mapper 接口中的方法时,都会进入invoke方法:
@OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{...}参数含义如下:
proxy:当前代理对象。method:当前被调用的方法。args:调用方法时传入的参数。
也就是说,当我们执行:
userInfoMapper.selectOne(userInfo);实际并不会进入某个手写的实现类,而是进入动态代理中的invoke方法。
方法调用逻辑
首先获取当前调用的方法名,并处理Object类中的方法:
StringmethodName=method.getName();if(method.getDeclaringClass()==Object.class){returnmethod.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:
TypegenericReturnType=method.getGenericReturnType();if(genericReturnTypeinstanceofParameterizedType){List<Object>objects=selectList(statementId,args);returnobjects;}这里通过method.getGenericReturnType()获取方法的返回值类型。
如果返回值是参数化类型,例如:
List<UserInfo>那么它属于ParameterizedType,此时调用selectList。
如果不是集合类型,则默认调用:
selectOne(statementId,args);所以这段逻辑可以简单理解为:
- Mapper 方法返回
List<T>:执行selectList。 - Mapper 方法返回普通对象:执行
selectOne。
通过这一步,Mapper 接口方法就和底层 SQL 执行逻辑关联起来了。
测试方法
下面编写一个测试方法,通过SqlSessionFactory创建SqlSession,再通过getMapper获取 Mapper 代理对象:
packageicu.wzk.test;importicu.wzk.bean.Resources;importicu.wzk.bean.SqlSession;importicu.wzk.bean.SqlSessionFactory;importicu.wzk.bean.SqlSessionFactoryBuilder;importicu.wzk.dao.UserInfoMapper;importicu.wzk.model.UserInfo;importjava.io.InputStream;publicclassTest02{publicstaticvoidmain(String[]args)throwsException{InputStreaminputStream=Resources.getResourceAsStream("sqlMapConfig.xml");SqlSessionFactorysqlSessionFactory=newSqlSessionFactoryBuilder().build(inputStream);SqlSessionsqlSession=sqlSessionFactory.openSession();UserInfouserInfo=newUserInfo();userInfo.setUsername("wzk");UserInfoMapperuserInfoMapper=sqlSession.getMapper(UserInfoMapper.class);System.out.println("userInfoMapper: "+userInfoMapper);System.out.println(userInfoMapper.selectOne(userInfo));}}测试流程如下:
- 读取
sqlMapConfig.xml配置文件。 - 构建
SqlSessionFactory。 - 通过
openSession()获取SqlSession。 - 调用
getMapper(UserInfoMapper.class)获取 Mapper 代理对象。 - 调用 Mapper 接口方法执行查询。
对应的截图如下所示:
运行结果
执行之后,控制台输出结果如下:
log4j:WARN No appenders could be foundforlogger(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 WHEREusername=? UserInfo(id=1,username=wzk,password=<PASSWORD>,age=18)对应的截图如下所示:
从运行结果可以看到,我们已经不需要手写UserInfoMapper的实现类,也不需要在 DAO 中手动拼接statementId。
现在的调用方式变成了:
UserInfoMapperuserInfoMapper=sqlSession.getMapper(UserInfoMapper.class);UserInfouserInfo=userInfoMapper.selectOne(queryParam);这一步完成后,框架的使用方式已经更接近 MyBatis:
- 开发者只需要定义 Mapper 接口。
- 框架负责生成代理对象。
- 代理对象根据接口名和方法名生成
statementId。 - 底层继续复用已有的
selectOne、selectList查询逻辑。
这样就减少了 DAO 层的重复代码,也消除了手写statementId带来的硬编码问题。
错误速查卡
| 症状 | 根因 | 定位 | 修复 |
|---|---|---|---|
打印userInfoMapper时也走了 SQL 查询路径 | 没有在invoke中判断method.getDeclaringClass() == Object.class,toString被当作 Mapper 方法 | DefaultSqlSession.getMapper的invoke逻辑 | 在拼装statementId之前先做Object方法透传 |
控制台报statement id not found: xxx | statementId没有使用类全限定名.方法名规则,配置文件中 namespace 或 id 不匹配 | 检查 XML 的namespace与id,对比运行时拼接值 | 保持method.getDeclaringClass().getName() + "." + method.getName()规则,XML 同步 |
返回List<T>时只返回了第一条数据 | 在分发逻辑里一律调用了selectOne,没有用ParameterizedType判断 | invoke中genericReturnType instanceof ParameterizedType分支 | 用method.getGenericReturnType()区分列表与单对象 |
getMapper调用报ClassCastException | Proxy.newProxyInstance返回Object,调用方未做泛型强转,或接口未传入 | return (T) proxyInstance;与new Class[]{mapperClass} | 确保传入的是接口Class,返回处做强转 |
log4j 警告No appenders could be found | 没有log4j.properties或log4j.xml | 运行时启动日志 | 增加 log4j 配置或显式BasicConfigurator.configure(),与本框架功能无关可忽略 |
| 同一个 Mapper 接口被加载多次产生多个代理 | 没有缓存MapperProxyFactory,每次getMapper都新建 | DefaultSqlSession.getMapper与配置注册表 | 引入MapperRegistry缓存knownMappers,MyBatis 官方做法 |
接口中没有声明throws Exception但代理内部抛了受检异常 | JDK 代理不会自动包装受检异常,且invoke声明throws Throwable | 编译错误或UndeclaredThrowableException | 在invoke内部 try/catch 统一包装为运行时异常,与 MyBatisExceptionUtil.unwrapThrowable一致 |
作者:武子康的个人博客