【MyBatis 源码拆解系列】MyBatis 插件底层设计原理

欢迎关注公众号 【11来了】 ,持续 MyBatis 源码系列内容!

在我后台回复 「资料」 可领取编程高频电子书
在我后台回复「面试」可领取硬核面试笔记

文章导读地址:点击查看文章导读!

感谢你的关注!

MyBatis 源码系列文章:
(一)MyBatis 源码如何学习?
(二)MyBatis 运行原理 - 读取 xml 配置文件
(三)MyBatis 运行原理 - MyBatis 的核心类 SqlSessionFactory 和 SqlSession
(四)MyBatis 运行原理 - MyBatis 中的代理模式
(五)MyBatis 运行原理 - 数据库操作最终由哪些类负责?
(六)MyBatis 运行原理 - 执行 Mapper 接口的方法时,MyBatis 怎么知道执行的哪个 SQL?
(七)MyBatis 运行原理 - JVM 级别缓存能力设计:MyBatis 的一、二级缓存如何设计?

MyBatis 设计的目的是将 SQL 执行的相关操作进行抽象封装,当研发人员需要操作数据库时,不需要重复去编写建立连接、执行 SQL、返回结果等代码,提升研发效率;但作为组件,MyBatis 为了不对业务造成入侵,一定会只对通用能力进行封装,那么就会在一些个性化场景中没有较好的支撑, 在不增加 MyBatis 源码复杂度的情况下,如何提供更个性化的业务支撑呢?

答案: 预留扩展点

组件通常都会预留出 扩展点 ,来供研发人员对原流程进行扩展和改写,而且这些扩展点往往都是在组件的不同时期执行的,因此想要使用好扩展点,我们还必须要熟悉对应组件的执行流程

常见的有:MyBatis 中的拦截器、Spring 中的各个扩展点等等,当有一些个性化的需求,例如分库分表、分页查询、黑名单 SQL、应用层慢 SQL 统计等等都可以利用 MyBatis 中的插件来实现,

接下来就会介绍一下 MyBatis 中插件的设计原理,可以借鉴于自己日常项目、组件的开发

背景知识

1、MyBatis 插件的使用限制

MyBatis 的插件只能对 4 种组件的方法进行拦截:

Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)    主要用于sql重写
ParameterHandler (getParameterObject, setParameters)    用于参数处理
ResultSetHandler (handleResultSets, handleOutputParameters)    用于结果集二次处理
StatementHandler (prepare, parameterize, batch, update, query)    用于 JDBC 层的控制
2、4 种组件介绍

MyBatis 插件提供了对 4 种组件的拦截,先简单介绍 4 种组件:

  • Executor:MyBatis 核心接口之一,定义了数据库的基本操作,最终数据库的操作都是交给 Executor 来完成的
  • ParameterHandler:为 SQL 语句的占位符绑定实参数据
  • ResultSetHandler:将从数据库中查询到的结果集进行处理
  • StatementHandler:MyBatis 核心接口之一,在 Executor 内部会通过 StatementHandler 去完成 SQL 的执行

接下来主要介绍一下 StatementHandler 这个接口,后边会基于该接口实现一个 应用层的慢 SQL 查询插件

  • StatementHandler 接口作用

StatementHandler 的目的是执行 SQL 语句,通过创建 Statement 对象来完成参数的绑定、SQL 的执行、结果的处理

StatementHandler 有多个实现类,这里主要介绍 PreparedStatementHandler

image-20240929112159486

BaseStatementHandler 作为一个抽象类,只提供了一些基础的参数绑定的操作,具体的查询操作还是在 PreparedStatementHandler 中定义,这里以 query() 方法为例介绍:

// PreparedStatementHandler # query()
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
  PreparedStatement ps = (PreparedStatement) statement;
  ps.execute();
  return resultSetHandler.handleResultSets(ps);
}

在上边的代码主要进行 3 个步骤:

  • 拿到 SQL 语句:获取 PreparedStatement
  • 执行 SQL 语句:通过 PreparedStatement 的 execute() 去进行对应 SQL 的执行
  • 处理 SQL 结果:通过 resultSetHandler 去处理查询出来的结果

PreparedStatement 和 Statement 介绍:

接口位置: 这两个接口位于 java.sql.* 包内部:

  • Statement:将需要执行的 SQL 语句发送给数据库执行
  • PreparedStatement: 继承自 Statement 接口,用于执行已经预编译好的 SQL 语句

如下,PreparedStatement 的特性在于可以对 SQL 进行预编译,即通过下边的 prepareStatement() 方法对 SQL 语句进行预编译,之后再通过 set 方法去设置对应参数,编译后的 PreparedStatement 之后可进行复用:

// 预编译
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?");
// 设置参数值
ps.setInt(1);

而对于 Statement 来说,无法进行复用:

// 创建 Statement
Statement statement = conn.createStatement();
// 执行 SQL
statement.executeQuery("SELECT * FROM user WHERE id = 1");

因此,使用时更倾向于 PreparedStatement,因为 PreparedStatement 不仅可以复用,而且可以 防止 SQL 注入

如何防止 SQL 注入?

如果只是将参数进行简单的拼接,如下:

-- 传入 id 参数
String id = "drop table user;";
-- 拼接 id
String sql = "SELECT * FROM user WHERE id = " + id;
-- 最终的 SQL
SELECT * FROM user WHERE id = 1;drop table user;

可以看到,如果直接对传入的 id 进行参数拼接,就会存在 SQL 注入 的风险,而使用 PreparedStatement 之后,通过 set 方式 去设置参数,就可以避免这个问题:

-- 预编译 SQL
PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?");
-- 设置参数值
ps.setString("1;drop table user;");
-- 最终的 SQL
SELECT * FROM user WHERE id = '1;drop table user;';

MyBatis 插件使用

使用 MyBatis 插件只需要两个步骤:

  • 自定义插件,实现 Interceptor 接口
  • 在 MyBatis 的配置文件中指定插件的位置

步骤一:自定义插件

之前说了 StatementHandler 用于将 Statement 交给数据库执行,因此在自定义插件中,对 StatementHandler 的 query、update、batch 这三个方法进行拦截,计算 SQL 耗时:

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

    // 定义的慢 SQL 阈值
    private long threshold;

    public Object intercept(Invocation invocation) throws Throwable {
        long begin = System.currentTimeMillis();
        Object res = invocation.proceed();
        long end = System.currentTimeMillis();
        long costTime = end - begin;
        Object[] args = invocation.getArgs();
        // 获取被代理对象,用于拿到执行的 SQL 语句
        StatementHandler target = (StatementHandler) invocation.getTarget();
        if (costTime >= threshold) {
            // 获取执行的 SQL 语句
            String boundSql = target.getBoundSql().getSql();
            // 慢 SQL 告警
            System.out.println("sql语句: " + boundSql + " 执行耗时:" + costTime + "毫秒,进行告警!");
        }
        return res;
    }
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
    public void setProperties(Properties properties) {
        this.threshold = Long.parseLong(properties.getProperty("threshold"));
    }
}

步骤二:在 mybatis-config.xml 文件指定插件

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <plugins>
        <plugin interceptor="com.github.yeecode.mybatisdemo.interceptors.DefaultInterceptor">
            <property name="threshold" value="10"/>
        </plugin>
    </plugins>

	<!--...-->
</configuration>

MyBatis 插件实现原理

原理探究

MyBatis 通过代理实现了插件的执行,MyBatis 只针对 4 种组件进行插件的拦截:Executor、ParameterHandler、ResultSetHandler、StatementHandler

这 4 种组件的创建位置都在 Configuration 中创建(工厂模式),以 StatementHandler 为例:

// Configuration # newStatementHandler()
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
  // 1、创建一个 StatementHandler
  StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
  // 2、插件的核心方法:创建 StatementHandler 的代理
  statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
  return statementHandler;
}

核心方法在第二步,通过 InterceptorChain 的 pluginAll() 方法去创建对应的代理,并且已经添加好对应的拦截器了,进入该方法:

// InterceptorChain # pluginAll()
public Object pluginAll(Object target) {
  for (Interceptor interceptor : interceptors) {
    target = interceptor.plugin(target);
  }
  return target;
}

这里会去遍历 interceptors ,里边包含了所有已经加载好的拦截器

拦截器什么时候加载的? 拦截器定义在了 xml 中,那么在解析 xml 文件时,就可以去加载对应的拦截器了

继续进入到 Interceptor 的 plugin() 内部:

// Interceptor # plugin()
default Object plugin(Object target) {
  return Plugin.wrap(target, this);
}

这里提供了一个 Plugin 工具类来完成插件的包装,并且 Plugin 自身实现了 InvocationHandler ,作为 JDK 动态代理的拦截器来调用自定义的 MyBatis 插件,详细的进入 wrap() 内部:

// Plugin
public class Plugin implements InvocationHandler {
    public static Object wrap(Object target, Interceptor interceptor) {  
        // 1、获取拦截器的 @Intercepts 的内容  
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);  
        Class<?> type = target.getClass();  
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);  
        if (interfaces.length > 0) {  
            // 2、创建动态代理,Plugin 作为拦截器
            return Proxy.newProxyInstance(  
                    type.getClassLoader(),  
                    interfaces,  
                    new Plugin(target, interceptor, signatureMap));  
        }  
        return target; 
    } 
} 

这里 target 就是被拦截的 4 种组件 Executor、StatementHandler …,针对这 4 种组件创建动态代理,拦截器是 Plugin,因此执行 4 种组件时,会进入 Plugin 拦截:

// Plugin # invoke
public class Plugin implements InvocationHandler{
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            Set<Method> methods = signatureMap.get(method.getDeclaringClass());
            // 1、判断该方法是否被拦截
            if (methods != null && methods.contains(method)) {
				// 2、执行拦截器的方法
                return interceptor.intercept(new Invocation(target, method, args));
            }
            return method.invoke(target, args);
        }
    }
}
实际案例

比如有三个自定义的插件 DefaultInterceptor1、DefaultInterceptor2、DefaultInterceptor3,对 StatementHandler 的方法进行拦截,那么会通过先生成 StatementHandler 的代理对象,在拦截器中去执行插件的方法,最终的关系如下图,执行流程为:

1、执行 StatementHandler 被拦截的方法

2、走到代理对象 1 中,执行 DefaultInterceptor3

3、走到代理对象 2 中,执行 DefaultInterceptor2

4、走到代理对象 3 中,执行 DefaultInterceptor1

image-20240929145517667

提问环节

问题 1:MyBatis 为什么不直接将各个插件串为链表,直接串行调用呢?

如果将各个插件串为链表,去执行各个插件的功能,那么就无法满足改写 SQL、计算调用耗时的场景

通过代理的话,对于被拦截的方法创建代理,这样在插件内部我们可以自己去 控制被代理方法执行的时机 ,这种方式相对于链表来说更加灵活一些

image-20240929143402845

问题 2:MyBatis 如何去加载各个插件?如果在 SpringBoot 中使用 MyBatis 插件,如何去加载对应插件?

插件在 mybatis-config.xml 文件中配置了位置,因此加载 xml 文件时就可以找到对应的插件进行加载

如果在 SpringBoot 使用 MyBatis 插件,通过 @Bean 注解,将自定义的插件交给 Spring 的 Bean 容器管理,让 Spring 可以拿到自定义的插件,其他的都会由 mybatis 和 springboot 整合的 starter 去完成加载的工作

问题 3:是否了解了 MyBatis 整个插件的执行流程,以及各个类之间的关联?

主要流程:

  • 代理的创建: Executor、StatementHandler … 等 4 种被拦截的组件会由工厂类 Configuration 来创建,在创建的时候就会通过 InterceptorChain 类去创建对应的代理对象
  • 插件的创建: 针对每一个拦截器都会创建一层的代理对象,通过 Plugin 包装了代理对象创建的过程,并且使用了 Plugin 来作为代理的拦截器
  • 执行流程: 当执行 StatementHandler 时,就会走到代理的拦截器 Plugin,执行其对应的插件之后,又会走到内部代理对象的拦截器 Plugin 中执行对应的插件 …
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

11来了

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值