Mybatis (1) 拓展机制

622 阅读7分钟

系列文章共三篇

JDBC 与 Mybatis

在理解 Mybatis 架构之前,可以先了解下 JDBC 的元素层次,Mybatis 的功能增强也是基于 JDBC 的这套分层结构的。 Screen Shot 2021-03-29 at 6.45.06 PM.png 其中

  • JDBC Driver Manager:负责管理数据库驱动,屏蔽数据库底层实现差异,为上层应用提供统一的 JDBC 访问 API
  • Connection:一个 Connection 代表一个会话,会话是 SQL 执行的通道,多会话可并发执行。
  • Statement:SQL 语句执行的容器,负责 SQL 语句的生成、参数处理、提交处理、返回结果处理
    • ResultSet:数据查询返回对象,附带 cursor 属性
    • Parameter:针对预编译语句、存储过程等,通过 setX 系列函数,设置语句入参。

再来看 Mybatis 的抽象架构:

  • 在数据处理层
    • Executor 驱动执行,整合 Mybatis 的元素(包括最关键的 MapperStatement)
    • StatementHandler 贴近 JDBC Statement 概念,将 Mybatis 的元素转换成标准 JDBC Statement 可理解的语句
    • Executor、StatementHandler、ParameterHandler、ResultSetHandler 之间的为嵌套管理,外层元素可初始化内层元素
  • 框架支撑层
    • 连接管理、数据源管理增强了 JDBC Driver Manager,例如数据库连接池特性就在该层实现
    • Configuration 管理:读取用户配置后,进行各类工厂方法的初始化,Mybatis 几乎所有的元素都注册于 configuration 实例类。
    • MapperStatement 动态绑定:指 mapper 接口类和动态 SQL 语句之间的绑定,动态 SQL 语句可以来自注解或 XML。动态绑定依赖 JAVA 动态代理特性,实际绑定动作为懒加载。
    • 数据类型转换:JDBC TYPES 到 JAVA TYPES 的转换处理器,可拓展
  • 用户接口
    • 配置读取:支持 XML 和 JVA API 配置
    • Mapper 接口:用户和框架的交互口,也是 Mybatis 动态代理的核心依赖

Screen Shot 2021-03-30 at 12.37.56 PM.png

拓展机制概览

Mybatis 提供了两个层级的拓展方式 重写配置类 和 插件拓展,前者的拓展能力是后者的超集。 但由于前者可以彻底改变 Mybatis 的核心流程,一旦出现问题将会产生严重影响,所以没有极为特殊的需求,请慎重使用,作者原文告诫如下:

In addition to modifying core MyBatis behaviour with plugins, you can also override the Configuration class entirely. Simply extend it and override any methods inside, and pass it into the call to the SqlSessionFactoryBuilder.build(myConfig) method. Again though, this could have a severe impact on the behaviour of MyBatis, so use caution.

Screen Shot 2021-03-29 at 11.07.06 PM.png

相比于重写,Mybatis 配置类大多数特性也是通过配置文件做个性化配置的,参阅官方文档 Mybatis Configuration,有几大类可配置:

大类作用
properties预定义公用变量,可以实现 账号/密码 等信息的复用
settings更改 Mybatis 运行时的状态,例如是否开启缓存、下划线和驼峰转换
typeAliases别名简写,引用 Entity 等可以不再需要写全路径
typeHandlers拓展点JDBC Types 到 Java Types 的类型转换处理器,Mybatis 有大量内置,也可以配置拓展新类型。
objectFactory拓展点在 ResultSetHandler 内创建返回结果的时候,会用 objectFactory 创建对象实例,可以配置拓展新类型
plugins拓展点Mybatis 插件体系,插件需要在配置文件内注册生效。后文会展开说
environments拓展点多数据源配置,可拓展 Transaction 相关实现
databaseIdProvider指定数据容器类型,如果 XML 内 statement 也配置了 databaseId,则优先加载 databaseId 匹配的 statement
mappers接口文件定义,填写需要加载的 Mapper 的位置

重写配置类

重写配置类可以让 Mybatis 变得不是 Mybatis,通过重写配置类,几乎可以改写整个框架

那在什么情况下需要重写配置类?

我们假定这么一个需求:定义一个 mapper 接口类,叫做 UserMappper  ,其中有获取用户列表的函数,我们期望返回一个分页对象,而不是一个数据库实体(Entity)或者 实体列表。

public interface UserMapper {
    IPage<User> selectActiveUserList(Integer limit, Integer offset);
}

查看 MapperMethod  的源码,并不知晓 IPage 是什么对象,也不知道该如何赋值。

// file => /org/apache/ibatis/binding/MapperMethod.java:57
public Object execute(SqlSession sqlSession, Object[] args) {
    // ......
	case SELECT:
        if (method.returnsVoid() && method.hasResultHandler()) {
            executeWithResultHandler(sqlSession, args);
            result = null;
        } else if (method.returnsMany()) {
            result = executeForMany(sqlSession, args);
        } else if (method.returnsMap()) {
            result = executeForMap(sqlSession, args);
        } else if (method.returnsCursor()) {
            result = executeForCursor(sqlSession, args);
        } else {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = sqlSession.selectOne(command.getName(), param);
            if (method.returnsOptional()
                && (result == null || !method.getReturnType().equals(result.getClass()))) {
                result = Optional.ofNullable(result);
            }
        }
    // ......
}

因此我们需要继承重写 MapperMethod#execute ,我们会添加如下分支判断,用于处理 IPage 类型的返回

else if(IPage.class.isAssignableFrom(method.getReturnType())) {
    result = executeForIPage(sqlSession, args);
}

改写完成后,我们要想办法把改写对象注册到 Configuration  上去,查阅源码后我们发现,两者之间存在如下引用关系,由于我们只能改变 Configuration 直接挂载的对象,所以为了挂载 MapperMethod,我们需要继承改写整条链路。

Configuration -> MapperRegistry- > MapperProxyFactory -> MapperProxy -> MapperMethod

最后在框架初始化的地方,挂载自定义 Configuration 实例对象即可

// 重写配置类
class MyConfiguration extends Configuration {
    @Override
    public MapperRegistry getMapperRegistry() {
        return new YourMapperRegistry();
    }
} 


// 使用重写后的配置类,初始化框架
SqlSessionFactory sqlSessionFactory =
                new SqlSessionFactoryBuilder().build(myConfiguration);

通过案例我们发现,如果要重写的对象,离 Configuration 越远,所需要更改的链路就越长,出错的概率就越高。再则由于更改的都是框架核心特性,一旦改写出现 BUG,都会造成不可预期的业务影响。

插件体系

拓展切面

针对 SQL 执行全流程,Mybatis 提供了 4 个拓展切面,细节展开如下:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

image.png

官网的 DEMO 中,在实现 Interceptor 接口后,通过 @Intercepts@Signature  注解可以告诉 Mybatis 框架插件期望的注入点,随后在配置中注册插件即可挂载。

初始化流程

Configuration  中通过 InterceptorChain  持有插件,插件按照在配置文件中定义的顺序放入职责链。

4 个切面初始化的顺序如下,对应前文提到嵌套层次。

1)newExecutor -> interceptorChain.pluginAll(executor)
    2)newStatementHandler -> interceptorChain.pluginAll(statementHandler);
        3-1)newParameterHandler -> interceptorChain.pluginAll(parameterHandler);
        3-2)newResultSetHandler -> interceptorChain.pluginAll(resultSetHandler);

interceptorChain.pluginAll 的逻辑很直白,按顺序加载职责链上的插件,前后插件之间循环嵌套。

// file => /org/apache/ibatis/plugin/InterceptorChain.java:29
public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);
    }
    return target;
}

其中 interceptor.plugin

// file => /org/apache/ibatis/plugin/Plugin.java:43
public static Object wrap(Object target, Interceptor interceptor) {
    // 获取切入点配置
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    // 获取和 type 和 signatureMap 匹配的接口定义,该函数会递归获取 type 的父类接口
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
        // 使用代理模式生成新的 executor 对象
        return Proxy.newProxyInstance(
            type.getClassLoader(),
            interfaces,
            // Plugin 内会对需要拦截的方法进行二次过滤
            new Plugin(target, interceptor, signatureMap));
    }
    return target;
}


// file => /org/apache/ibatis/plugin/Plugin.java:57
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        // 只对注册需要拦截的方法进行拦截
        Set<Method> methods = signatureMap.get(method.getDeclaringClass());
        if (methods != null && methods.contains(method)) {
            return interceptor.intercept(new Invocation(target, method, args));
        }
        // 然则直接放行
        return method.invoke(target, args);
    } catch (Exception e) {
        throw ExceptionUtil.unwrapThrowable(e);
    }
}

到这里我们计算下 @Intercepts@Signature 和代理层级数量之间关系

ProxyNumber = SUM(
    Distinct(Plugin1#Intercepts#Signature.types),
    Distinct(Plugin2#Intercepts#Signature.types),
    ......
)

以如下插件示例,对 type 去重得 Executor 和 StatementHandler 2 个接口,则该插件会生成两个 Proxy 对象。

@Intercepts({
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
    @Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SqlCostTimeInterceptor implements Interceptor {
}

洋葱模型

mybatis 插件体系最后呈现的是经典的洋葱模型。
当所有插件加载完毕后,Mybatis 最后会生成 4 个洋葱(Executor、StatementHandler、ParameterHandler、ResultSetHandler)。 插件越先定义,数据结构上会在越内层,执行顺序上会越靠后。 Screen Shot 2021-03-31 at 11.07.19 AM.png

Before 和 After 落到到代码层面理解,就是调用 invocation.proceed() 前后,插件的两次代码执行窗口。

public class SqlCostTimeInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
       // Before
       try {
           return invocation.proceed();
       } finally {
          // After
       }
    }
}

总结

Mybatis 优良的拓展性来自一切皆可配置,包括框架的核心特性也挂载在 Configuration 之上。
对于绝大多数的拓展特性,可以通过配置和插件体系完成实现。
除非你想封装实现一个 Mybatis-Plus,不然不要轻易尝试重写配置类,后续的 Mybatis 新特性合并和原生社区的插件兼容都会是个头疼的问题。

参考资料