拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MyBatis源码解析:多态在框架设计中的极致运用

我们平时写 Java 项目几乎每天都在和 MyBatis 打交道。但大多数人对 MyBatis 的理解停留在写个 Mapper 接口、写个 XML、然后自动就能查出数据这个黑盒层面。真正让我对 MyBatis 刮目相看的一次是我想去回答一个问题Mapper 接口明明没有实现类为什么调用userMapper.selectById(1)它就能把结果查回来顺着这个问题往下挖我发现自己不是在看一个 ORM 框架而是在看一个把多态用到骨子里的设计范本。这篇文章我就从源码角度把 MyBatis 设计里多态性的几处典型体现拆开聊聊适合学过 SSM/SpringBoot、对 MyBatis 原理有好奇心的同学阅读。1. 先把多态从教科书里拽出来MyBatis 里的多态长什么样1.1 教科书三要素 vs 框架里的运行时多态学校教的多态往往只讲三要素继承、重写、父类引用指向子类对象。可你拿这三条去套 MyBatis会卡住。Mapper 接口它没有父类也没有实现类更不存在Parent p new Child()这种代码但你确实能通过一个接口类型的变量调用到真正的数据库操作逻辑。这说明框架层面的多态比教科书那种继承树上的多态要宽得多。我认为更准确的理解是多态的本质是面向接口的约定 运行时的具体分派。只要代码里依赖的是抽象类型而运行时塞进去的是某种具体实现多态就已经发生了。调用方不需要关心背后是谁在执行只关心我这个接口方法能不能得到我预期的结果。MyBatis 整个框架就是建立在这个理念上的几乎所有核心组件都以接口形式暴露然后在运行时根据配置、上下文、参数类型选择不同的实现类去干活。我用一个生活类比帮自己记忆家里墙上的插座就是一个接口约定三孔是统一的规范。电视机、洗衣机、手机充电器各自插上去能正常工作但插座的调用者——也就是家里的人——完全不关心里面走的是交流电还是直流电也不关心是哪个牌子的电视机。多态在 MyBatis 里就是这种插座和电器的关系核心接口是插座各种实现类是按统一标准制造的电器。1.2 一条 SQL 的执行链路上多态藏在哪几个环节我先说一条 SQL 从接口方法到数据库结果的完整链路这条链路会贯穿整篇文章Mapper接口方法 → MapperProxyJDK动态代理 → SqlSessionTemplate → ExecutorSimpleExecutor / ReuseExecutor / BatchExecutor / CachingExecutor → StatementHandlerRoutingStatementHandler 再路由到具体 StatementHandler → ParameterHandler → TypeHandler处理Java类型与JDBC类型互转 → JDBC Statement / ResultSet → ResultSetHandler这条链路上除了最底层的 JDBC几乎每一层都是接口 多个实现类的结构。Executor 有多个实现类、StatementHandler 有多个实现类、ParameterHandler 有默认实现、TypeHandler 有一大堆实现类、ResultSetHandler 也有默认实现。这不是巧合而是一种刻意的设计把每一个容易变化的点都抽象成接口把变化隔离在实现类里上层代码面向抽象编程。我在读源码之前一直以为多态只是面向对象里一个偏理论的考点。读完 MyBatis 才发现框架设计者早就把多态用成了解决复杂性最直接的工具。2. Mapper 接口的无中生有动态代理才是多态的极致玩法2.1 MapperProxyFactory 和 MapperProxy 是怎么生成代理对象的先回答开头那个问题Mapper 接口为什么会没有实现类却能调用因为 MyBatis 在运行时用 JDK 动态代理给接口生成了一个代理对象它实现了你的 Mapper 接口但方法体里的逻辑由 InvocationHandler 接管。也就是说实现类是被 JVM 在运行时动态生成的源码里自然找不到UserMapperImpl这种类。关键代码在MapperProxyFactory和MapperProxy里。MapperProxyFactory.newInstance()内部做的事情翻译成大白话说就是return Proxy.newProxyInstance( mapperInterface.getClassLoader(), new Class[]{mapperInterface}, new MapperProxy(sqlSession, mapperInterface, methodCache) );Proxy.newProxyInstance是 JDK 自带的动态代理工具它会在运行时生成一个$ProxyN类。这个类实现了你的 Mapper 接口并且所有方法调用都会转发到MapperProxy.invoke()。MapperProxy.invoke()的逻辑简化后大概是这样的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); } // 否则取出缓存的 MapperMethod执行具体 SQL 逻辑 return cachedInvoker(method).invoke(proxy, method, args, sqlSession); }如果你在断点调试时看userMapper的运行时类型会发现它显示为com.sun.proxy.$ProxyN后面跟一串十六进制这就是动态代理生成的类。这个运行时类型和接口类型是两个完全不同的东西但接口引用却能指向它这就是多态最典型的形态编译期看是接口运行期是另一个真实对象。2.2 MapperMethod 的方法路由一个方法对应一套执行逻辑代理只是外壳真正干活的是MapperMethod。MyBatis 在解析 Mapper 接口时会把每个方法都封装成一个MapperMethod里面包含两个核心成员SqlCommand和MethodSignature。SqlCommand记录了这个方法对应的是哪条 SQL通过mapperInterface.getName() . method.getName()组装出 statementId以及 SQL 的类型INSERT/UPDATE/DELETE/SELECT。MethodSignature解析方法的返回类型、参数数量、参数注解等元信息。MapperMethod.execute()里就是典型的多态分派switch (command.getType()) { case INSERT: { ... } case UPDATE: { ... } case DELETE: { ... } case SELECT: if (methodSignature.returnsMany()) { // 返回 List走 query 查询列表 } else if (methodSignature.returnsMap()) { // 返回 Map走 selectMap } else if (methodSignature.returnsCursor()) { // 返回游标 } else { // 返回单个对象 } }同一个select方法因为返回类型不同单个对象、List、Map、Cursor执行路径完全不同但调用方感知不到传个不同类型的返回值就能拿到不同结果。这不是传统意义上的继承重写而是更灵活的策略型多态一个接口方法在内部根据元数据路由到不同处理逻辑。2.3 为什么 MyBatis 要放弃手写 DAO 实现类我刚开始写 MyBatis 时其实有过疑虑以前写 JDBC 要写一大堆 DAO 实现类UserDaoImpl、OrderDaoImpl里面全是重复的Connection、PreparedStatement、ResultSet处理代码。MyBatis 直接省掉了实现类这一层让业务代码只依赖 Mapper 接口。这个看似偷懒的做法的好处是数据访问契约被固定在了接口上。接口就是一张菜单你需要查用户就声明User selectById(Long id)你需要查列表就声明ListUser selectList(...)。至于这张菜单背后的厨房是怎么运作的——用原生 JDBC、用 MyBatis、还是未来换成 JPA——只要接口不变Service 层代码就不用动。但这里有个我实际踩过的坑需要提醒Mapper 接口方法不允许重载overload。因为 MyBatis 生成 statementId 用的是类名 方法名它根本不含参数签名。如果你写了两个同名方法比如ListUser selectByName(String name)和ListUser selectByName(String name, Integer age)MyBatis 在启动阶段就会因为 statementId 冲突直接报错。所以写 Mapper 接口时方法名必须全局唯一。这一点和普通 Java 接口的语义完全不同容易被忽略。3. SqlSource 的策略分支静态 SQL、动态 SQL 与注解 SQL 的多态分工3.1 四种 SqlSource 各自管什么在 MyBatis 里SqlSource是负责生成BoundSql的组件。BoundSql里装着最终要执行的 SQL 语句已把#{xxx}替换成?和参数映射信息。SqlSource接口的代码极其简单public interface SqlSource { BoundSql getBoundSql(Object parameterObject); }就这么一个方法。但它的实现类分成了几种每种对应一种 SQL 来源场景StaticSqlSource、DynamicSqlSource、RawSqlSource、ProviderSqlSource。我整理了一个对比表SqlSource 实现类应用场景解析时机本质StaticSqlSourceSQL 语句在 XML 中写死只有#{}占位符解析阶段SQL 文本已固定直接生成 BoundSqlDynamicSqlSourceSQL 中包含if、where、foreach等动态标签每次执行时动态解析需要根据参数动态拼接 SQLRawSqlSourceSQL 中仅包含sql、include等构建期可处理的片段构建阶段预处理内部持有 StaticSqlSourceProviderSqlSource使用SelectProvider、InsertProvider等注解方法调用时由 Provider 类生成 SQL由外部 Java 类动态产生 SQL为什么要拆出这么多实现类因为不同来源的 SQL生命周期完全不同。静态 SQL 在启动解析完成后就固定了每次执行直接用即可动态 SQL 每次执行时都要根据入参重新判断条件不能提前固化Provider 则是完全把 SQL 生成逻辑交给用户自定义 Java 类灵活性最强。把这三类模式统一在一个SqlSource接口之下上层拿到的一律是能给我产出 BoundSql 的东西这就是策略型多态算法不同但对外契约一致。3.2 SqlNode 树动态标签其实是一棵多态语法树动态 SQL 的实现方式非常有趣。MyBatis 在解析 XML 时会把if、where、set、foreach这些标签解析成一个SqlNode对象树每个节点实现同一个接口public interface SqlNode { boolean apply(DynamicContext context); }每个节点只负责自己的语义IfSqlNode判断 test 条件是否成立成立就把子节点内容追加到上下文WhereSqlNode负责在拼接前处理第一个AND/OR并在有必要时补上WHERE关键字ForEachSqlNode负责把集合参数循环拼接成IN (?,?,?)的形式TrimSqlNode负责去掉多余的前后缀。我印象最深的是WhereSqlNode的设计。写 SQL 的人都知道WHERE子句跟着动态条件时最容易出错条件都不成立时不能输出WHERE成立时第一个条件前面又不能带AND。MyBatis 把这两个脏活累活全部收敛在WhereSqlNode这一个节点里调用方只需要在 XML 里写where标签其他破事不用管。这一套节点树的设计本质上就是把动态 SQL 语法抽象成了一组多态节点。每个节点是某个语法单元的独立实现互相不认识但都能在DynamicContext上执行apply()。把IfSqlNode换成ChooseSqlNode整体解析流程完全不用改这就是多态解耦的价值。补充一个实际经验如果某个查询的 SQL 结构在绝大多数情况下是固定的只是偶尔有可选条件尽量别把整条 SQL 都写成动态 SQL。因为DynamicSqlSource在每次执行都要重新走一遍节点树解析性能开销虽然不算大但在超高 QPS 场景下积累起来很可观。能把 SQL 写死就写死让 SQL 落到StaticSqlSource这才是性能更好的姿势。3.3 RawSqlSource 为什么经常被误解RawSqlSource这个名字非常有迷惑性我刚接触时以为它是原始SQL后来才发现它一点也不原始。它处理的是这样的情况XML 里有sql iduserColumnsid, name, email/sql这样的片段然后SELECT include refiduserColumns/ FROM user。这种 SQL 在解析阶段就已经能确定最终文本不需要每次执行时再看参数动态判断但它确实引用了其他 SQL 片段不能直接当作纯静态 SQL 处理。所以RawSqlSource的构造阶段会通过SqlSourceBuilder把#{}占位符替换成?把 include 引用的内容合并进来最终封装成一个StaticSqlSource然后每次getBoundSql()实际上是调内部StaticSqlSource的方法。从设计角度理解MyBatis 希望同一套对外接口内部尽量把能固定的提前固定实在需要每次判断的才走动态解析。能静态就静态必须动态才动态这是一个非常务实的设计取向。4. 执行链路上的策略选择Executor、StatementHandler 的多态路由4.1 BaseExecutor 用模板方法固定骨架三种子类各干各的Executor是 MyBatis 真正干活的执行器它负责和 JDBC 打交道、管理一级缓存、处理事务等。Executor接口的实现结构是一个抽象父类BaseExecutor加三个具体子类SimpleExecutor、ReuseExecutor、BatchExecutor。这里用到的是模板方法模式而模板方法模式本身就是多态的一种应用。BaseExecutor把查询、更新、缓存、事务等公共逻辑写死在父类里但把最核心的doUpdate、doQuery、doQueryCursor、doFlushStatements定义为抽象方法让子类各自实现。三个子类的差异我用一个表总结实现类核心行为适用场景SimpleExecutor每执行一次 SQL 就创建一个新的 Statement用完就关默认通用场景ReuseExecutor将 Statement 按 SQL 文本缓存到 Map 中同一条 SQL 复用同一个 Statement重复执行大量相同 SQL 的服务BatchExecutor把多条 SQL 攒到一批通过 JDBC 的 addBatch/executeBatch 批量提交批量插入、批量更新Configuration.newExecutor()里决定用哪个子类的逻辑也很有意思Executor executor; if (ExecutorType.BATCH executorType) { executor new BatchExecutor(this, transaction); } else if (ExecutorType.REUSE executorType) { executor new ReuseExecutor(this, transaction); } else { executor new SimpleExecutor(this, transaction); }默认情况下executorType是SIMPLE但你可以通过全局配置defaultExecutorTypeBATCH也可以在Options注解里针对单个方法指定。这就是标准的策略模式同一套 Executor 接口不同场景换不同实现。调试时有个小技巧在 IntelliJ 里给 Executor 相关的方法打断点看变量的运行时类型是SimpleExecutor还是CachingExecutor还是BatchExecutor。如果看到CachingExecutor包在外面说明二级缓存已经生效了。这是个非常直观的验证多态的方式。4.2 RoutingStatementHandler一个路由分发器管三种 StatementHandlerStatementHandler负责把BoundSql里的参数值设置到 JDBC 的PreparedStatement上并执行 SQL。MyBatis 并没有让业务代码直接选择StatementHandler而是搞了一个RoutingStatementHandler做路由分发。RoutingStatementHandler的构造器里根据StatementType枚举创建委托对象switch (statementType) { case STATEMENT: delegate new SimpleStatementHandler(...); break; case PREPARED: delegate new PreparedStatementHandler(...); break; case CALLABLE: delegate new CallableStatementHandler(...); break; default: throw new ExecutorException(Unknown statement type: statementType); }默认是PREPARED对应 JDBC 的PreparedStatement可以防 SQL 注入且能预编译STATEMENT对应Statement适合少量不需要参数化的场景CALLABLE对应存储过程调用。RoutingStatementHandler自己和SimpleStatementHandler、PreparedStatementHandler、CallableStatementHandler实现的是同一个StatementHandler接口但它的任务不是真正执行 SQL而是把调用转发给内部的那个委托对象。多态在这里表现为路由选择 委托调用的组合。上层代码永远只看到StatementHandler接口实际干活的可能是三个中的任意一个而且这个选择发生在构造阶段后面完全透明。4.3 CachingExecutor 是装饰器还是多态MyBatis 二级缓存用的CachingExecutor是装饰器模式这一点很多文章提过。但我想换个角度说装饰器本身不是多态但它必须依赖多态才能成立。CachingExecutor实现了Executor接口构造时持有另一个真正的 Executor比如SimpleExecutor。它的query()方法先查二级缓存没命中的话再调 delegate 的query()查询数据库查询结果再存入缓存。这就是标准的包了一层。关键在于创建链是new CachingExecutor(new SimpleExecutor(...))从外面看CachingExecutor就是一个 Executor调用方毫无感知。这种透明替换能力正是多态带来的接口引用能指向任何实现类包装类可以嵌套包装对象的行为在运行时被组合、被增强而业务代码不需要区分它到底拿到的是哪个实现。二级缓存执行时序上有几个细节值得注意执行update操作时会先 flush 缓存、清空相关缓存区域再委托底层执行 SQL以确保缓存不出现脏读。这点在设计自己的缓存装饰器时也是通用的经验。5. 数据类型的多态翻译官TypeHandler 与注册表机制5.1 TypeHandler 双向通道的设计意味数据库里的VARCHAR、INTEGER、TIMESTAMP和 Java 里的String、Integer、Date并不是天然一一对应的。MyBatis 解决这个问题的方式是定义了一个TypeHandler接口每个 Java 类型对应一个或几个处理器专门负责Java类型 → JDBC类型和JDBC类型 → Java类型的双向转换。看接口定义就明白了public interface TypeHandlerT { void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; T getResult(ResultSet rs, int columnIndex) throws SQLException; T getResult(CallableStatement cs, int columnIndex) throws SQLException; }setParameter负责写参数时把 Java 对象转成 JDBC 驱动认识的值getResult负责读结果时把数据库返回的值转成 Java 对象。每个实现类只处理自己关心的那一种类型。比如IntegerTypeHandler就专心处理Integer和INTEGER的关系StringTypeHandler专心处理String和VARCHAR的关系谁也不越界。这种设计思路和现实里的翻译官一模一样英语翻译只负责英语日语翻译只负责日语开会时根据发言人国籍现场指派不同的翻译官。多态的好处就是每个类型自己定义自己的翻译规则互不污染新增一种类型只需要新增一个 TypeHandler不用改动任何既有代码。5.2 TypeHandlerRegistry 的查找顺序决定了自定义类型怎么生效TypeHandler 再多也得有个地方注册和查找这就是TypeHandlerRegistry。它的内部是若干张 Map分别以JdbcType、JavaType等作为 key。查找的逻辑大体是先按(javaType, jdbcType)组合精确匹配匹配不到再只按javaType找最后兜底到unknownTypeHandler。这也解释了为什么有些自定义 TypeHandler 明明注册了却没生效——大概率是精确匹配阶段就命中了默认 Handler没走你的自定义逻辑。我用过一个很常见的坑做例子如果StringTypeHandler已经按String.class注册而我再注册一个自定义的StringJsonTypeHandler同样认领String.class由于注册表里同一个 JavaType 只能有一个默认处理器最终生效的可能是后来覆盖的那个也可能是先注册的那个取决于注册顺序。所以注册自定义 TypeHandler 时要么明确指定MappedJdbcTypes缩小匹配范围要么在全局配置里放最后靠覆盖行为保证生效。5.3 实战一个枚举 TypeHandler 和 JSON 字段处理开发中我最常用到自定义 TypeHandler 的场景是枚举。MyBatis 默认的EnumTypeHandler存的是枚举的name()字符串EnumOrdinalTypeHandler存的是枚举的ordinal()序号。这两种都存在隐患name()一改枚举名旧数据就废了ordinal()中间挪动一个枚举顺序数据就全错位了。业务上最稳的做法是存一个稳定的 code 值比如订单状态0表示待支付、1表示已支付。此时自定义一个 TypeHandlerMappedTypes(OrderStatusEnum.class) public class OrderStatusEnumTypeHandler extends BaseTypeHandlerOrderStatusEnum { Override public void setNonNullParameter(PreparedStatement ps, int i, OrderStatusEnum parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public OrderStatusEnum getNullableResult(ResultSet rs, String columnName) throws SQLException { return OrderStatusEnum.of(rs.getInt(columnName)); } Override public OrderStatusEnum getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return OrderStatusEnum.of(rs.getInt(columnIndex)); } Override public OrderStatusEnum getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return OrderStatusEnum.of(cs.getInt(columnIndex)); } }然后注册时用typeHandlersPackage扫包或者单独注册到全局配置里。这样一来 Java 代码里永远操作枚举对象数据库里存的是稳定整数两边互不干扰。JSON 字段同理。我常看到有人把对象序列化成 JSON 字符串存在String类型的字段里然后每次读写都手动反序列化。开源社区有现成的JacksonTypeHandler可以直接注册使用原理就是重写setNonNullParameter时objectMapper.writeValueAsString()getNullableResult时objectMapper.readValue()。这类转换逻辑收敛在 TypeHandler 里业务代码几乎不用感知字段的存储形态这也是多态封装复杂性的直接收益。6. 多态的高级扩展姿势插件拦截器、MyBatis-Plus 与面试思辨6.1 Plugin.wrap 如何给四大接口套上代理MyBatis 的插件机制本质上是在已经多态化的接口之上再叠加一层动态代理。它允许拦截的对象只有四个接口Executor、StatementHandler、ParameterHandler、ResultSetHandler。为什么偏偏是这四个因为它们覆盖了 SQL 执行链路的所有关键节点执行器、语句处理器、参数设置、结果映射。只要拦截这几个抽象层就能横切整个 SQL 生命周期。拦截逻辑封装在Plugin.wrap()里它也是 JDK 动态代理。Interceptor可以通过Intercepts注解声明自己要拦截哪个接口的哪个方法、参数类型匹配是什么样的。Plugin这个InvocationHandler在invoke()里会检查当前方法是否命中声明命中就交给用户拦截器处理没命中就直接走原方法。这里有个我在开发中特别注意的点多个插件会按照配置顺序层层包装最终形成一个代理套代理的链。最后一个注册的插件包装在最外层最先执行。如果想让分页插件在自定义业务插件之前或之后执行顺序就要在配置阶段规划好。多态在这里表现为对象可以在运行时被不断替换成增强版而且增强对业务代码完全透明。6.2 MyBatis-Plus 不重写 SQL 执行而是借多态扩展点做增强经常有人问 MyBatis 和 MyBatis-Plus 的区别。从多态的视角看MyBatis-Plus 并没有重写 MyBatis 的执行引擎而是在 MyBatis 已经定义好的扩展点上做增强。BaseMapper接口里预置了insert、deleteById、selectById、selectList这些通用方法。MP 通过DefaultSqlInjector在启动时把对应 SQL 注入到MappedStatement里让这些接口方法变成真正的可执行 SQL。这个过程完全不用写 XML但底层走的还是SqlSource、Executor那一套链路。分页功能也是这样。MP 的MybatisPlusInterceptor实现了 MyBatis 的Interceptor接口内部维护一个ListInnerInterceptor里面挂着分页拦截器、乐观锁拦截器等。分页时它会拦截Executor的query方法改写 SQL 加上LIMIT执行完再查询总数返回。这个逻辑放在 MyBatis 的多态体系里看就是在接口抽象层替换了一个代理实现代理内部增加行为然后委托给真实执行器本质还是多态与代理的配合。所以回答MP 和 MyBatis 的区别时与其背一堆功能列表不如说MP 是站在 MyBatis 的扩展点上做增强没有多态和动态代理这些增强根本无处挂载。6.3 当面试官问MyBatis 哪里用到了多态时怎么答才显水平这个问题我面试别人时经常问。大多数人会答Executor有SimpleExecutor、BatchExecutor、ReuseExecutor然后就没下文了。这个答案没错但只有一个点显得比较薄。我更希望听到的答案是分层次的第一层接口与策略分层的多态。Executor、StatementHandler、SqlSource、TypeHandler都是接口加多实现的结构Configuration在运行时根据配置、SQL来源或类型注册表选择具体实现这是策略型多态。第二层运行时的无实现类多态。Mapper 接口根本没有实现类MyBatis 通过Proxy.newProxyInstance动态生成代理对象把接口方法路由到MapperMethod去执行。这是对多态更高级的运用调用方只知道接口连实现类都不存在完全由运行时生成。第三层扩展点的多态包装。Interceptor 插件机制可以把Executor等接口对象用动态代理层层包装实现缓存、分页、审计等功能业务代码无感知。装饰器模式、责任链模式在这里都依托多态实现。这样回答展现的不只是背过概念而是真正理解了一个框架如何在复杂场景里利用多态控制变化。如果再能提一句每次执行 SQL 我先看userMapper的运行时类型是不是$Proxy再看Executor是不是CachingExecutor就能立刻判断缓存和代理有没有生效这种经验细节基本就是一个对源码有实际研究的候选人了。最后说点我自己的体会。我刚接触 MyBatis 的时候也试图靠背多态三要素去应付项目面试总觉得那几个特性离真实开发很远。直到我沿着 Mapper 接口往下追源码看见MapperProxy、SqlSource、Executor、TypeHandler这一整条抽象链才真正明白框架设计里的多态不是为了展示语法而是在把变化和稳定隔离开来。稳定的接口负责契约变化的具体实现负责细节两者通过运行时绑定产生联系于是扩展变得简单替换变得透明代码也变得可以维护。理解了这一点再看 MyBatis 里的任何功能你都会有一种原来是这么串起来的的通透感。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门