资讯动态

手写迷你Mybatis:从动态代理到插件扩展的完整实战

发布时间:2026/10/16 14:15:14 来源:尧图企业网站定制
我把“自定义 Mybatis”理解成两件事一是自己动手把一个迷你版 Mybatis 从零写出来搞懂它背后的代理、反射、JDBC 封装这些核心机制二是基于真实项目在 Mybatis 之上做定制化扩展比如自定义插件、类型处理器、字段自动填充。这两件事我都做过而且做完之后最大的感受是之前几年用 Mybatis 都白用了只停留在“会写 Mapper.xml”的程度根本没有真正理解它。这篇文章会把我手写迷你 Mybatis 的完整过程、组件拆分思路、核心代码、踩过的坑以及写完后对 Mybatis 源码的新认知一起梳理出来。适合刚接触框架底层原理的 Java 开发也适合想在工作里做 Mybatis 二次扩展但不知道从哪下手的同学。1. 自定义的第一步不是写代码而是把 Mybatis 的活拆开1.1 为什么“自定义”比“背诵源码”更有效很多人学源码有个误区打开 GitHub 把 Mybatis 源码 clone 下来从 Configuration 开始一行行读读两周之后发现脑子里全是类名但永远说不清一条 SQL 是怎么从 Mapper 接口走到数据库的。我自己的体会是真正有效的路径是反过来的——先把自己当成 Mybatis 的作者从零设计一套“能用”的持久层框架然后再拿自己写的代码和官方源码做对比。这样一来每个类、每个接口存在的理由都变得非常清晰因为那些组件不是别人强加给你的概念而是你自己在解决问题的过程中自然生长出来的。想清楚这一点后我做的第一件事就是拆解Mybatis 到底替我们干了哪些活1.2 一张清单理清 Mybatis 的职责边界以一条最简单的查询举例User user userMapper.findById(1L);这一行代码背后Mybatis 实际完成了六件事解析 Mapper 接口的方法找到它对应的 SQL 语句建立数据库连接拿到 Connection 对象把方法入参绑定到 SQL 里的占位符执行 JDBC 查询拿到 ResultSet把 ResultSet 的每一行数据反射成 User 对象把这个 User 对象返回给调用方如果你不想用 Mybatis自己拿 JDBC 裸写上面每一件事都得手动做。最原始的写法大概是Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(select * from user where id ?); ps.setLong(1, 1L); ResultSet rs ps.executeQuery(); User user new User(); if (rs.next()) { user.setId(rs.getLong(id)); user.setName(rs.getString(name)); } rs.close(); ps.close(); conn.close();这段代码问题很多SQL 和 Java 代码硬耦合、结果集映射代码大量重复、连接关闭容易遗漏、没有复用机制。Mybatis 的价值并不是做了什么高深的技术而是把这段重复劳动抽象成了“一次配置、到处使用”。所以我手写迷你 Mybatis 时定的原则很简单能用 JDK 自带的动态代理和反射完成就不用任何第三方依赖。这样写出来的东西虽然简陋但每一行代码都能对应到 Mybatis 的某个真实组件。2. 手写的核心骨架从注解映射到动态代理2.1 先定义一套最简 Mapper 注解体系原版 Mybatis 支持 XML 和注解两种方式。我手写版本选择了注解方式因为注解在代码里表现更直观也更容易让人理解“映射”的本质。我定义了一组极简注解Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Select { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Insert { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Update { String value(); } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Delete { String value(); }注意注解的 Retention 必须是RUNTIME否则程序运行期间拿不到注解信息。这是初学者最常踩的坑后面会详细说。接着定义一个 Mapper 接口public interface UserMapper { Select(select * from user where id ?) User findById(Long id); Insert(insert into user(name, age) values(?, ?)) int insert(User user); Select(select * from user) ListUser findAll(); }这个接口没有实现类但调用方却希望sqlSession.getMapper(UserMapper.class)之后能直接调方法。实现这个“魔法”靠的就是 JDK 动态代理。2.2 动态代理怎么让接口“无中生有”地产生实现核心思路不给接口写实现类而是让getMapper返回一个代理对象。这个代理对象拦截所有方法调用根据方法上的注解找到 SQL再去执行 JDBC。代理工厂代码如下public class MapperProxyFactoryT { private final ClassT mapperInterface; private final SqlSession sqlSession; public MapperProxyFactory(ClassT mapperInterface, SqlSession sqlSession) { this.mapperInterface mapperInterface; this.sqlSession sqlSession; } SuppressWarnings(unchecked) public T newInstance() { MapperProxyT proxy new MapperProxy(sqlSession); return (T) Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[]{mapperInterface}, proxy ); } }核心的MapperProxy是InvocationHandler的实现每次调用接口方法都会进到invokepublic class MapperProxyT implements InvocationHandler { private final SqlSession sqlSession; public MapperProxy(SqlSession sqlSession) { this.sqlSession sqlSession; } 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); } Select select method.getAnnotation(Select.class); if (select ! null) { return sqlSession.selectList(select.value(), args); } // Insert / Update / Delete 同理 throw new UnsupportedOperationException(method not supported: method.getName()); } }这里有一个细节值得展开讲为什么需要判断Object.class.equals(method.getDeclaringClass())因为代理对象被调用时任何方法都会走进invoke包括toString()、hashCode()这些 Object 自带的方法。如果不加这个判断对代理对象执行toString()会抛异常或者被当成 SQL 方法处理。这是动态代理使用中的一个典型坑。2.3 SqlSession 和 Executor 的分层设计写完代理之后SqlSession 负责调度Executor 负责真正跟数据库打交道。这种分层不是多余的它让“调用入口”和“执行引擎”解耦后续加缓存、加插件都会容易很多。public interface SqlSession { T T getMapper(ClassT type); E ListE selectList(String sql, Object[] args, ClassE resultType); int update(String sql, Object[] args); void commit(); void rollback(); void close(); }public interface Executor { E ListE query(String sql, Object[] args, ClassE resultType); int update(String sql, Object[] args); void commit(); void rollback(); void close(); }默认实现DefaultSqlSession负责把 Mapper 方法的返回值类型解析出来然后调用 Executor。这一步在代码实现上有一个容易忽略的点方法返回值可能是单个对象也可能是List甚至是int不同返回类型对应的处理路径不同。我当时是用一个简单的约定处理的如果方法返回List直接返回 Executor 查询到的列表如果返回单个对象取列表的第一个元素如果是int返回受影响行数。public class DefaultSqlSession implements SqlSession { private final Executor executor; public DefaultSqlSession(Executor executor) { this.executor executor; } Override public T T getMapper(ClassT type) { return new MapperProxyFactory(type, this).newInstance(); } Override public E ListE selectList(String sql, Object[] args, ClassE resultType) { return executor.query(sql, args, resultType); } Override public int update(String sql, Object[] args) { return executor.update(sql, args); } Override public void commit() { executor.commit(); } // rollback / close 省略 }到了这里“Mapper 接口方法调用 → 代理 → SqlSession → Executor”这条链路已经通了。但真正写 Executor 的时候才会遇到整个手写过程中最折磨人的部分参数绑定和结果集映射。3. 参数绑定与结果集映射最容易翻车的两座山3.1 PreparedStatement 的参数绑定要绕开三个坑Executor 的核心实现还是 JDBC但比裸写 JDBC 多一点根据传入的 Object[] 动态绑定占位符。最简单的方式是遍历数组逐个setObject。public class SimpleExecutor implements Executor { private final DataSource dataSource; public SimpleExecutor(DataSource dataSource) { this.dataSource dataSource; } Override public E ListE query(String sql, Object[] args, ClassE resultType) { try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { if (args ! null) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } } try (ResultSet rs ps.executeQuery()) { ListE result new ArrayList(); while (rs.next()) { E obj resultType.getDeclaredConstructor().newInstance(); mapping(rs, obj); result.add(obj); } return result; } } catch (Exception e) { throw new RuntimeException(query failed, e); } } Override public int update(String sql, Object[] args) { try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { if (args ! null) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } } return ps.executeUpdate(); } catch (Exception e) { throw new RuntimeException(update failed, e); } } }这里有几个坑值得展开说说都是我在实际运行后才发现的第一args为空的情况。如果 SQL 没有占位符调用ps.setObject(1, arg)会直接报错。所以必须先判空再循环。第二传入参数的类型。setObject虽然万能但遇到java.util.Date之类的类型JDBC 驱动不一定能正确识别。我当时遇到的场景是把Date当查询条件结果查出来是空的。后来查资料才发现java.util.Date需要先转成java.sql.Timestamp或java.sql.Date再绑定。这个问题在标准 Mybatis 里由TypeHandler体系解决手写版本可以先特殊处理if (args[i] instanceof Date) { ps.setTimestamp(i 1, new Timestamp(((Date) args[i]).getTime())); } else { ps.setObject(i 1, args[i]); }第三千万不要为了省事用字符串拼接 SQL。我测试阶段试过直接把接到的参数拼进 SQL 字符串看起来方便但只要有单引号就会让 SQL 语法出错更别说注入风险。后面所有的执行逻辑都统一走PreparedStatement占位符。3.2 拿到 ResultSet 之后反射映射有哪些隐藏门道结果集映射是手写框架里最有成就感也最繁琐的部分。基础实现思路很直接通过ResultSetMetaData拿到所有列的标签名然后用反射找到实体类对应的字段赋值。private void mapping(ResultSet rs, Object obj) throws Exception { ResultSetMetaData metaData rs.getMetaData(); int columnCount metaData.getColumnCount(); for (int i 1; i columnCount; i) { String columnLabel metaData.getColumnLabel(i); Object value rs.getObject(i); String fieldName hump(columnLabel); Field field obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); } }这里的hump方法是下划线转驼峰private String hump(String columnLabel) { if (!columnLabel.contains(_)) { return columnLabel; } StringBuilder sb new StringBuilder(); boolean upper false; for (char c : columnLabel.toCharArray()) { if (c _) { upper true; } else if (upper) { sb.append(Character.toUpperCase(c)); upper false; } else { sb.append(c); } } return sb.toString(); }为什么用getColumnLabel而不是getColumnName这也是测试中踩出来的。某些数据库在给列起别名后getColumnName返回的仍是原始列名而getColumnLabel返回别名。这两者在绝大部分场景下相同但在特殊 SQL 场景下会不一致。为了兼容性用getColumnLabel更稳。反射赋值也有一个必须处理的问题实体字段类型和数据库返回类型不一致。比如数据库TINYINT类型返回 Java 的Integer实体却定义了Boolean。直接field.set(obj, value)会抛IllegalArgumentException。我当时在set之前增加了类型判断和转换逻辑比如把Integer转为Boolean把时间类型统一转成Date。这块的生产级解法就是 TypeHandler手写阶段做几个常用的判断就够了。3.3 insert 语句的另一个隐藏需求拿到自增主键手写阶段我一开始没太在意 insert 返回自增主键的问题直到真正要用user.getId()时才意识到JDBC 的executeUpdate只返回受影响行数不返回自增主键。想拿主键必须在创建PreparedStatement时显式声明PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);执行executeUpdate之后通过ps.getGeneratedKeys()获取ResultSet keys ps.getGeneratedKeys(); if (keys.next()) { generatedKey keys.getObject(1); }再把 generatedKey 回填到实体的 id 字段上。这个功能在原版 Mybatis 里对应useGeneratedKeys属性平时看起来不起眼但用到的场景非常多。手写一遍之后我对这个配置的理解就深刻了。4. 设计一个能用的 SqlSession生命周期、一级缓存和动态 SQL4.1 一个 SqlSession 该什么时候打开、提交、关闭手写框架时最容易犯的错误是把SqlSession当成一个“全局单例”来用。最开始我图省事在工具类里创建了一个共享的 SqlSession所有请求都用它。结果很快遇到问题一个线程的操作还没提交另一个线程的查询把未提交的数据也读进去了事务边界完全失控。正确做法是一个 SqlSession 对应一个业务操作单元通常就是一次请求或一个事务。它的生命周期应该是业务开始时创建 SqlSession获得 mapper 代理执行一组关联操作全部成功后 commitfinally 里 close在 JDK 7 的 try-with-resources 出现之后可以让 SqlSession 实现AutoCloseable但要注意如果业务操作出现异常必须显式 rollback不能只关闭连接。try (SqlSession session sqlSessionFactory.openSession()) { UserMapper mapper session.getMapper(UserMapper.class); User user mapper.findById(1L); user.setName(newName); mapper.update(user); session.commit(); }4.2 一级缓存与事务边界的正确联动写到了 Executor 之后我开始琢磨怎么加缓存。Mybatis 的一级缓存默认开启它的作用域是 SqlSession也就是同一个 SqlSession 内执行相同的 SQL第二次直接返回缓存结果不再查数据库。手写实现其实不难在 Executor 外包一层缓存即可public class CachingExecutor implements Executor { private final Executor delegate; private final MapString, Object cache new HashMap(); public CachingExecutor(Executor delegate) { this.delegate delegate; } Override public E ListE query(String sql, Object[] args, ClassE resultType) { String key sql : Arrays.toString(args); if (cache.containsKey(key)) { return (ListE) cache.get(key); } ListE result delegate.query(sql, args, resultType); cache.put(key, result); return result; } Override public int update(String sql, Object[] args) { cache.clear(); return delegate.update(sql, args); } }实现完这个类之后我开始理解官方文档里那句“一级缓存只在 SqlSession 生命周期内有效”背后的原因缓存 map 直接挂在 Executor 实例上而 Executor 实例由 SqlSession 持有。SqlSession 一关闭Executor 就被回收缓存自然消亡。同时也要注意到执行 update、insert、delete 时必须清空缓存否则会出现“更新了数据库但查询结果还是旧值”的严重不一致。这个坑我确实踩过写 update 方法时忘了清缓存排查了很久才反应过来。4.3 动态 SQL用拼接规则替换 if/else 墙原版 Mybatis 的if、where、foreach是它的招牌功能。手写阶段不可能也没必要完全复刻但至少要理解它的核心机制动态 SQL 本质上是“根据条件拼接字符串”的规则引擎。我用一个极简的条件拼接实现来做演示。例如要支持“姓名非空则模糊查询、年龄非空则精确匹配”public String buildQuerySql(String name, Integer age) { StringBuilder sql new StringBuilder(select * from user where 1 1); ListObject params new ArrayList(); if (name ! null !name.isEmpty()) { sql.append( and name like ?); params.add(% name %); } if (age ! null) { sql.append( and age ?); params.add(age); } return sql.toString(); }这个方法看起来简单但它揭示了 Mybatiswhere标签的本质自动处理 where 关键字和多余 and/or 前缀。既然手写我就在 SQL 里用where 1 1这种常用技巧先固定一个永真条件后续所有条件都能安全追加 and不需要判断“这是不是第一个条件”。这种方式在简单场景下很好用但生产环境不建议大面积依赖 11因为可能影响 SQL 性能优化。真正生产级做法是把 SQL 片段解析成 AST再按上下文拼接。但理解了字符串拼接的原理后再去看原版 Mybatis 的SqlSourceBuilder、DynamicSqlSource会容易很多。5. 自定义扩展点实战写一个能拦截 SQL 的插件5.1 用动态代理实现插件的核心机制手写框架的乐趣在于一旦核心链路跑通就能在关键位置留下“钩子”供外部扩展。Mybatis 的插件机制本质上也是在 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这四种组件上做动态代理在方法调用前后插入自定义逻辑。我的迷你框架里定义了这样一个拦截器接口public interface Interceptor { Object intercept(Invocation invocation) throws Throwable; }Invocation是拦截点信息的封装包含目标对象、目标方法和参数public class Invocation { private final Object target; private final Method method; private final Object[] args; public Invocation(Object target, Method method, Object[] args) { this.target target; this.method method; this.args args; } public Object proceed() throws Exception { return method.invoke(target, args); } }通过Plugin.wrap给 Executor 套上代理public class Plugin { public static Object wrap(Object target, Interceptor interceptor) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) - interceptor.intercept(new Invocation(target, method, args)) ); } }这样Executor 的任何方法调用都会先经过拦截器拦截器可以选择直接返回、修改参数、写日志或者调用invocation.proceed()放行。5.2 实战实现一个自动填充 updateTime 的插件自动填充createTime、updateTime是业务开发中最高频的扩展需求之一。在原版 Mybatis 里有MetaObjectHandler手写版本我直接用拦截器实现类似效果。思路是拦截update方法判断第一个参数是否是实体对象如果是就把updateTime和createTime填充好再放行。public class AutoFillInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); if (args ! null args.length 0 args[0] instanceof BaseEntity) { BaseEntity entity (BaseEntity) args[0]; if (entity.getCreateTime() null) { entity.setCreateTime(new Date()); } entity.setUpdateTime(new Date()); } return invocation.proceed(); } }使用方式Executor executor new SimpleExecutor(dataSource); executor (Executor) Plugin.wrap(executor, new AutoFillInterceptor()); SqlSession sqlSession new DefaultSqlSession(executor);这里要注意一个关键点拦截器的Intercepts注解声明很重要因为如果不声明拦截哪个类、哪个方法每个方法调用都会走拦截逻辑性能损耗大。原版 Mybatis 要求插件声明Intercepts({Signature(...)})目的就是精确匹配切入点。手写版本我没做这么复杂的匹配直接在所有方法前拦也能工作但不够精确。5.3 自定义 TypeHandler 处理枚举字段另一个常见扩展场景是枚举类型转换。数据库里通常存枚举的 code 值Java 实体里却想用一个枚举对象。手写框架里做一个简单的 TypeHandlerpublic interface TypeHandlerT { T fromValue(Object value); }比如我有一个状态枚举public enum UserStatus { ACTIVE(1), DISABLED(2); private final int code; UserStatus(int code) { this.code code; } public static UserStatus fromCode(int code) { for (UserStatus status : values()) { if (status.code code) { return status; } } return null; } }对应的 TypeHandlerpublic class UserStatusHandler implements TypeHandlerUserStatus { Override public UserStatus fromValue(Object value) { if (value null) { return null; } if (value instanceof Integer) { return UserStatus.fromCode((Integer) value); } return UserStatus.fromCode(Integer.parseInt(value.toString())); } }结果集映射里遇到UserStatus字段时不再直接field.set(obj, value)而是查到一个 TypeHandler 来转换。这个设计串起来之后我才真正理解 Mybatis 里TypeHandlerRegistry存在的价值——它就是一个“类型 → 处理器”的注册表。6. 手写过后回头看 Mybatis 源码的三点顿悟6.1 Configuration 为什么是“大管家”手写版框架跑通之后我开始回去看 Mybatis 源码第一个感受是Configuration这个类承担了太多职责。它不只是配置文件的存放对象还同时管理着MapperRegistry、TypeHandlerRegistry、InterceptorChain、Environment、MappedStatement等大量组件。可以说Configuration是 Mybatis 的“IOC 容器”。SqlSessionFactory 创建 SqlSession 时会把整个 Configuration 传进去后续所有组件都能从它身上找到自己需要的东西。这比我手写版本里把配置散落在各个类中要优雅得多。举一个具体例子我在手写版里解析注解是在MapperProxy.invoke里临时处理的每次调用都重新解析一次。而 Mybatis 在解析阶段就把每个 Mapper 方法解析成了MappedStatement存在 Configuration 里。调用时直接按方法名查节省了大量重复解析的耗损。这个优化思路在框架类代码里非常典型——把运行时的重复计算前置到初始化阶段。6.2 我们踩过的坑官方源码早有设计回头看自己踩坑的过程对照官方源码会发现几乎每一个坑都有对应的设计兜底。比如参数绑定时的空指针问题Mybatis 用ParameterMapping列表精确维护每个占位符对应的属性名、类型和 TypeHandler从根上避免了“凭感觉遍历”的问题。比如结果集映射里下划线转驼峰官方提供了mapUnderscoreToCamelCase配置还支持Results注解显式指定列映射关系。再有反射赋值的性能问题Mybatis 通过自定义Reflector把字段和 setter 方法的元数据缓存起来比每次调用都重新反射快很多。这些都说明一个成熟的框架不是功能堆砌而是对真实场景里各种边界问题的系统化解答。自己动手写一遍最大的收获就是把这些问题从“文档里的说明文字”变成了“亲身踩过的教训”。6.3 什么时候用 Mybatis什么时候可以不用有了手写经验之后我对工具选型也更清醒了。如果是几十行代码的脚本、一次性数据迁移、或者并发要求极高的高频单条查询直接使用 JDBC 或者 JdbcTemplate 反而更轻。如果项目有大量复杂动态 SQL、需要灵活的分页插件、需要和各种缓存中间件结合Mybatis 这类半自动 ORM 依然是首选。真正重要的是理解框架替我们做掉了什么以及哪些地方有扩展余地。最后分享几个写这个项目后才真正内化的细节第一个是写框架类代码时要刻意练习“面向接口编程”。我第一版直接写了DefaultSqlSession和SimpleExecutor的类实现所有调用都指到具体类型。后来想加缓存、加插件才发现处处被动。重构后抽出SqlSession、Executor接口扩展点一下子清晰起来。这个习惯比任何框架知识都值钱。第二个是测试时一定要用真实数据库跑一遍完整链路。我前期只用单元测试验证了代理和反射逻辑以为万事大吉结果第一次连上 MySQL 就遇到时间类型转换、自增主键拿不到等问题。这些坑在 mock 环境下完全暴露不出来。第三个是学会用“最小实现”去理解大框架。不需要等把所有细节都搞懂才开始写可以先搭一个能跑的迷你版本再对照官方源码逐步补全。这个过程比我读十遍源码都有效。如果你也在准备读 Mybatis 源码或者项目里正好有定制化需求我建议先花两三天写一个精简版。写完你再看Interceptor、TypeHandler、Configuration这些概念会觉得非常亲切因为它们和你亲手写出来的东西是一脉相承的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑