手写 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.ProxyJava 8 可用2026 年仍是默认实现方式statementId 类全限定名.方法名拼装规则✅ 已验证与 MyBatis 3.xMapperProxy的命名空间解析规则一致ParameterizedType判断返回ListT✅ 已验证method.getGenericReturnType()是 JDK 反射标准 APIObject方法透传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接口增加如下方法TTgetMapper(Class?mapperClass);修改完成后对应的截图如下DefaultSqlSession接下来在DefaultSqlSession中实现getMapper方法OverridepublicTTgetMapper(Class?mapperClass){ObjectproxyInstanceProxy.newProxyInstance(DefaultSqlSession.class.getClassLoader(),newClass[]{mapperClass},newInvocationHandler(){OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{StringmethodNamemethod.getName();if(method.getDeclaringClass()Object.class){returnmethod.invoke(this,args);}StringclassNamemethod.getDeclaringClass().getName();StringstatementIdclassName.methodName;TypegenericReturnTypemethod.getGenericReturnType();if(genericReturnTypeinstanceofParameterizedType){ListObjectobjectsselectList(statementId,args);returnobjects;}returnselectOne(statementId,args);}});return(T)proxyInstance;}对应的截图如下所示这个方法的核心作用是根据传入的 Mapper 接口类型动态生成一个代理对象。以后我们就不需要手写 Mapper 的实现类了。几个关键点如下Override表示该方法重写了接口中的方法。T T表示这是一个泛型方法返回值类型由调用方决定。getMapper(Class? mapperClass)接收一个 Mapper 接口的Class对象用于生成对应的代理对象。动态代理对象的创建代码如下ObjectproxyInstanceProxy.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类中的方法StringmethodNamemethod.getName();if(method.getDeclaringClass()Object.class){returnmethod.invoke(this,args);}这里的判断是为了处理toString()、equals()、hashCode()等方法。如果不做这个判断代理对象打印、比较时也会被当成普通 SQL 方法处理容易出现不符合预期的问题。SQL 语句标识符接下来生成statementIdString classNamemethod.getDeclaringClass().getName();String statementIdclassName . methodName;这里的规则是Mapper接口全限定名.方法名例如 Mapper 接口是icu.wzk.dao.UserInfoMapper调用的方法是selectOne那么最终生成的statementId就是icu.wzk.dao.UserInfoMapper.selectOne这样就可以和配置文件中的 SQL 语句进行匹配避免在 DAO 实现类中手动写死statementId。方法返回类型判断最后根据方法返回值类型决定调用selectList还是selectOneTypegenericReturnTypemethod.getGenericReturnType();if(genericReturnTypeinstanceofParameterizedType){ListObjectobjectsselectList(statementId,args);returnobjects;}这里通过method.getGenericReturnType()获取方法的返回值类型。如果返回值是参数化类型例如ListUserInfo那么它属于ParameterizedType此时调用selectList。如果不是集合类型则默认调用selectOne(statementId,args);所以这段逻辑可以简单理解为Mapper 方法返回ListT执行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{InputStreaminputStreamResources.getResourceAsStream(sqlMapConfig.xml);SqlSessionFactorysqlSessionFactorynewSqlSessionFactoryBuilder().build(inputStream);SqlSessionsqlSessionsqlSessionFactory.openSession();UserInfouserInfonewUserInfo();userInfo.setUsername(wzk);UserInfoMapperuserInfoMappersqlSession.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$161dc03ce SimpleExecutor getBoundSql: SELECT * FROM user_info WHEREusername? UserInfo(id1,usernamewzk,passwordPASSWORD,age18)对应的截图如下所示从运行结果可以看到我们已经不需要手写UserInfoMapper的实现类也不需要在 DAO 中手动拼接statementId。现在的调用方式变成了UserInfoMapperuserInfoMappersqlSession.getMapper(UserInfoMapper.class);UserInfouserInfouserInfoMapper.selectOne(queryParam);这一步完成后框架的使用方式已经更接近 MyBatis开发者只需要定义 Mapper 接口。框架负责生成代理对象。代理对象根据接口名和方法名生成statementId。底层继续复用已有的selectOne、selectList查询逻辑。这样就减少了 DAO 层的重复代码也消除了手写statementId带来的硬编码问题。错误速查卡症状根因定位修复打印userInfoMapper时也走了 SQL 查询路径没有在invoke中判断method.getDeclaringClass() Object.classtoString被当作 Mapper 方法DefaultSqlSession.getMapper的invoke逻辑在拼装statementId之前先做Object方法透传控制台报statement id not found: xxxstatementId没有使用类全限定名.方法名规则配置文件中 namespace 或 id 不匹配检查 XML 的namespace与id对比运行时拼接值保持method.getDeclaringClass().getName() . method.getName()规则XML 同步返回ListT时只返回了第一条数据在分发逻辑里一律调用了selectOne没有用ParameterizedType判断invoke中genericReturnType instanceof ParameterizedType分支用method.getGenericReturnType()区分列表与单对象getMapper调用报ClassCastExceptionProxy.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缓存knownMappersMyBatis 官方做法接口中没有声明throws Exception但代理内部抛了受检异常JDK 代理不会自动包装受检异常且invoke声明throws Throwable编译错误或UndeclaredThrowableException在invoke内部 try/catch 统一包装为运行时异常与 MyBatisExceptionUtil.unwrapThrowable一致作者武子康的个人博客