深入探索MyBatis插件:定制化扩展与增强应用

举报
薛伟同学 发表于 2025/01/20 17:22:10 2025/01/20
252 0 0
【摘要】 MyBatis插件是一种强大的扩展机制,可以对MyBatis的核心功能进行定制化扩展和增强。本文将深入研究MyBatis插件的机制和应用,详细解析插件的开发方式、扩展点以及常见应用场景。我们将探讨如何编写、注册和配置MyBatis插件,以及如何利用插件实现自定义功能,如日志记录、性能监控、审计等。

MyBatis 插件

MyBatis 的插件机制允许你在 MyBatis 的执行过程中动态地拦截方法调用,并在方法调用前后执行自定义的逻辑。这种机制使得在不修改原始代码的情况下,能够对 MyBatis 的行为进行增强或者定制化。

插件可以用于很多用途,比如日志记录、性能监控、参数加密解密等。通过插件机制,你可以非常灵活地定制 MyBatis 的行为,以满足特定的需求。

MyBatis 插件编写

通过本专栏前面的内容我们已经了解到,MyBatis 底层将 SQL 的执行交过了 Executor 来处理,而 Executor 又利用 StatementHandler、ParameterHandler、ResultSetHandler 这三个核心的操作类来处理 SQL 的实际执行。

所谓的插件,更确切的说就是拦截器,拦截上述 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的相关方法,在执行前后加上我们想要的逻辑即可。

插件机制的核心是通过实现 MyBatis 提供的 Interceptor 接口来创建插件。这个接口包含了三个方法:

  1. intercept:这是拦截方法调用的核心方法。在这个方法中,你可以对被拦截的方法进行增强或者修改。你可以在方法执行前后执行自定义逻辑,也可以决定是否执行原始方法。
  2. plugin:这个方法主要用于拦截器链式的传递,包装目标对象并返回一个代理对象。该代理对象会拦截目标对象的方法调用,以便在方法调用前后执行插件逻辑。
  3. setProperties:这个方法用于设置插件的属性。这些属性可以在插件配置时进行设置,并在插件初始化时传递给插件实例。

要创建一个自定义的插件,你需要实现 Interceptor 接口,并在插件的配置中指定该插件。在配置文件中,你可以使用 <plugins> 元素来指定插件,并在插件元素中使用 <plugin> 元素来定义每个插件的类和属性。

定义拦截器类

@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}),
})
public class HelloPlugin implements Interceptor {

    private String message;

    /**
     * 执行的拦截逻辑
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        System.out.println("传递的属性 message = " + message);
        System.out.println("开始执行 SQL ...");
        Object proceed = invocation.proceed();
        System.out.println("SQL 执行完毕 ...");
        return proceed;
    }

    /**
     * 拦截器的传递,这个方法就是固定这么写!
     */
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    /**
     * 获取拦截器相关的属性
     */
    @Override
    public void setProperties(Properties properties) {
        // 通过定义成员变量的方式,将属性进行赋值
        message = (String) properties.get("message");
    }
}

@Intercepts 注解

用于标识一个类是一个 MyBatis 插件,并且指定该插件要拦截的目标方法。它有一个 value 属性,类型为一个 @Signature 数组,用于指定要拦截的目标方法。

@Intercepts({
   @Signature(
       type = Executor.class,
       method = "update",
       args = {MappedStatement.class, Object.class}
   ),
   @Signature(
       type = Executor.class,
       method = "query",
       args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
   )
})
public class ExamplePlugin implements Interceptor {
   // 插件逻辑实现
}

@Signature 注解

用于定义要拦截的目标方法的签名。它有三个属性:

  • type:被拦截方法所在的接口或者类,通常为 Executor(常用)、StatementHandler(最常用)、ParameterHandler(不常用)、ResultSetHandler(不常用)。
  • method:要拦截的方法名,通常为 query、update。
  • args:要拦截方法的参数列表,注意这里的参数列表必须与上面的 type 类 下的 method 方法的参数列表一模一样,包括顺序!而且要注意导的包对不对…
@Signature(
   type = Executor.class,
   method = "update",
   args = {MappedStatement.class, Object.class}
)

关于 intercept 方法

刚刚介绍导 intercept 方法是拦截调用的核心方法,那么这个方法有一个参数 Invocation invocation,这个 invocation 就是我们拦截的目标对象、目标方法已经实际运行时的参数。

image.png

拦截 StatementHandler 的 prepare 方法

通过分析源码可以得知,StatementHandler 在 query 和 update 之前都会执行 prepare 来构建出 Statement,默认是 PreparedStatement,而我们要执行的 SQL 语句以及一些其他的参数都是放在这个 Statement 中的,所以当我们需要拦截执行,并处理 SQL 的时候,我们拦截 prepare 是非常舒服的,而且观察参数可以看到有数据库的连接对象,那么有了连接对象之后我们就可以像原来的 JDBC 编程一样,为所欲为了。

Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException;

当我们拦截 prepare 方法的时候,得到的 invocation 对象如下所示:
image.png

可以明显的看到,接下来将会执行的 SQL 语句以及参数情况。

当我们想要在程序中获取到 SQL 的时候可以一层一层调用 get 方法获取,如下:

RoutingStatementHandler routingStatementHandler = (RoutingStatementHandler) invocation.getTarget();
String sql = routingStatementHandler.getBoundSql().getSql();

我们也可以使用 MyBatis 提供的一个非常方便的反射工具 MetaObject,用这个类我们可以非常方便的获取和赋值目标的所有属性!

// 创建反射的元对象
MetaObject metaObject = SystemMetaObject.forObject(invocation);
// 通过字符串路径的方式即可拿到对应的内容,不需要一层一层的 get
String sql = (String)  metaObject.getValue("target.boundSql.sql");

我也可以直接粗暴的修改要执行的 SQL,原来是要执行一个 delete 操作,我直接改成的 select 操作!

metaObject.setValue("target.boundSql.sql", "select * from account where id = ?");

image.png

MyBatis 配置文件

<?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>
    
    <typeAliases>
        <typeAlias type="world.xuewei.mybatis.entity.Account" alias="Account"/>
    </typeAliases>

    <plugins>
        <plugin interceptor="world.xuewei.plugin.HelloPlugin">
            <property name="message" value="Hello"/>
        </plugin>
    </plugins>

    <environments default="default">
        <environment id="default">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://*.*.*.*/learn?useSSL=false&amp;characterEncoding=utf8"/>
                <property name="username" value="root"/>
                <property name="password" value="123456"/>
            </dataSource>
        </environment>
    </environments>

    <mappers>
        <mapper resource="mappers/AccountMapper.xml"/>
    </mappers>

</configuration>

这里要特别注意 <plugins> 标签的位置,放在别的位置可能 IDEA 就会报错,调整一下位置即可。

测试程序

public class PluginTest {

    private SqlSession sqlSession;

    @Before
    public void before() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        sqlSession = sessionFactory.openSession();
    }

    @After
    public void after() {
        sqlSession.commit();
    }

    @Test
    public void testPlugin() {
        AccountDao accountDao = sqlSession.getMapper(AccountDao.class);
        accountDao.getAll().forEach(System.out::println);
    }
}

image.png

实现原理

拦截器的解析

当我们编写好若干个拦截器后,配置在 MyBatis 的配置文件中,之后 MyBatis 在启动的时候就会去扫描这些拦截器并存储在 Configuration 对象中。通过原有的知识体系 这个解析的过程肯定是由 XMLConfigBuilder 来实现的。

private void parseConfiguration(XNode root) {
    try {
        // 省略加载 properties、settings、typeAliases 等标签 ...
        pluginElement(root.evalNode("plugins"));
        // 省略加载 environments、typeHandlers、mappers 等标签 ...
    } catch (Exception e) {
        throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
}

private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            // 按照顺序拿到配置的拦截器
            String interceptor = child.getStringAttribute("interceptor");
            // 拿到通过 properties 标签为拦截器配置的属性
            Properties properties = child.getChildrenAsProperties();
            // 反射创建拦截器对象在
            Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).newInstance();
            // 创建完对象后调用 setProperties 赋值属性
            interceptorInstance.setProperties(properties);
            // 添加到 Configuration 对象
            configuration.addInterceptor(interceptorInstance);
        }
    }
}

再次梳理一下 XMLConfigBuilder 的处理过程:

  1. parseConfiguration 处理 MyBatis 配置文件中的各个顶级标签,其中就有处理插件的方法 pluginElement。

  2. pluginElement 按照顺序解析 plugins 中的 interceptor,通过反射创建对象。

  3. 完成属性的赋值操作(setProperties),这个也就是我们在自定义插件的时候要实现的 setProperties 方法,通常我们要在插件中定义成员变量去接收。

    可以看到,在 MyBatis 启动的时候就已经将属性赋值给了插件!

  4. 将创建并赋值好的插件添加到 Configuration 核心配置对象中。

接下来要执行的一系列方法:

public class Configuration {
    // ...
    
    protected final InterceptorChain interceptorChain = new InterceptorChain();

    public void addInterceptor(Interceptor interceptor) {
        interceptorChain.addInterceptor(interceptor);
    }
   
    // ...
}
  1. Configuration 中维护了一个拦截器链(InterceptorChain)的对象,添加拦截器的时候本质上是添加给了这个对象。
public class InterceptorChain {

    // 本质上就是一个 ArrayList<Interceptor>
    private final List<Interceptor> interceptors = new ArrayList<Interceptor>();

    // 这个方法就比较关键了!表示将这些插件应用到目标对象
    public Object pluginAll(Object target) {
        for (Interceptor interceptor : interceptors) {
            // 按照顺序应用到目标对象,注意这里调用的竟然是我们在拦截器中定义的 plugin 方法!
            target = interceptor.plugin(target);
        }
        return target;
    }

    public void addInterceptor(Interceptor interceptor) {
        interceptors.add(interceptor);
    }

    public List<Interceptor> getInterceptors() {
        return Collections.unmodifiableList(interceptors);
    }
}
  1. InterceptorChain 本质上维护了一个 ArrayList<Interceptor> 存储所有的插件。其中还有一个非常关键的方法 pluginAll,稍后我们会详细分析。

至此拦截器的解析工作已经完成了!

  1. 解析工作首先由 XMLConfigBuilder 开始,在其核心的解析方法 parseConfiguration 中有一个步骤就是用来解析插件,也就是 pluginElement 方法
  2. pluginElement 方法会按照在 XML 文件中定义的顺序,依次通过反射创建拦截器对象,并完成 <property> 属性的赋值,并将拦截器对象添加给 Configuration 对象,整个过程都是在 for 循环中完成。
  3. Configuration 本身又是维护了一个拦截器链(InterceptorChain)的对象,其实是将拦截器添加给了它。InterceptorChain 其实就是一个 ArrayList<Interceptor>,最后其实所有的拦截器都在这个列表里面。

拦截器的应用

拦截器的解析过程完成后将所有的拦截器放置在了 Configuration 下的 InterceptorChain 下的 interceptors 中。

那么文章最开始我们说过,拦截器可以拦截的对象有四个:Executor、StatementHandler、ParameterHandler、ResultSetHandler。

为什么是这四个?这些拦截器是怎么应用到这四类对象上的?接下来我们就来看看这里的实现。

InterceptorChain 中有个非常关键的方法 pluginAll,接收一个 Target 目标,然后将所有的拦截器依次应用到这个目标。那我们就可以看看哪些地方用到了这个方法:

image.png

最开始的知识内容介绍导 Executor、StatementHandler、ParameterHandler、ResultSetHandler 这四个对象的创建的创建都是交给了 Configuration 对象,而创建的过程中就将这插件应用到了对应的 Executor、StatementHandler、ParameterHandler、ResultSetHandler!

除了这四个地方应用了插件,再也没有其他地方调用了这个方法,所以 MyBatis 的拦截器也只能拦截到这四类对象的方法。

image.png

这里扩展一下,Executor、StatementHandler、ParameterHandler、ResultSetHandler 是什么使用创建的呢?

Executor

我们原来在测试程序中都写过这样一段代码:

@Before
public void before() throws IOException {
   InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
   SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
   sqlSession = sessionFactory.openSession();
}

而 Executor 的创建就是在这个 openSession 方法中,SqlSessionFactory 这个接口的默认实现类是 DefaultSqlSessionFactory,通过源码跟踪可以看到最后会执行 DefaultSqlSessionFactory 的 openSessionFromDataSource,而这个方法中就创建了 Executor。

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
        final Environment environment = configuration.getEnvironment();
        final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
        tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
        // 这里调用了 Configuration 的方法,创建了 Executor
        final Executor executor = configuration.newExecutor(tx, execType);
        return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
        closeTransaction(tx);
        throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
}

StatementHandler

我们知道,SqlSession 将 SQL 的处理和执行交给了 Executor,而 Executor 又向下传递给了 StatementHandler,所以 StatementHandler 的创建必然是要交给实际的 Executor 来创建。

image.png

查阅源码可以发现,StatementHandler 是在 Executor 实际要执行 SQL 操作的时候(更新、查询)创建。在默认情况下,MyBatis 使用的是 SimpleExecutor,所以默认 StatementHandler 都是从 SimpleExecutor 的 更新和查询相关的方法创建的。

image.png

ParameterHandler 和 ResultSetHandler

这两个对象是要搭配着 StatementHandler 共同工作的,所以这三个是绑定的关系。查看源码,只有一个地方同时调用了 Configuration 的 newParameterHandler 和 newResultSetHandler,那就是 StatementHandler 接口的适配器实现类 BaseStatementHandler,他的构造方法创建了 ParameterHandler 和 ResultSetHandler。

image.png

拦截器的运行

拦截器的运行是仅跟着上一个步骤,我们再来看一下将拦截器应用在 Executor、StatementHandler、ParameterHandler、ResultSetHandler 的时候,调用的 pluginAll 方法:

public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        // 按照顺序应用到目标对象,注意这里调用的竟然是我们在拦截器中定义的 plugin 方法!
        target = interceptor.plugin(target);
    }
    return target;
}

这里调用的是我们在拦截器中定义的 plugin 方法,而这个 plugin 方法刚刚说的他这个方法其实就是一个固定的写法:

@Override
public Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

调用的 Plugin 类的静态包装 wrap 方法,返回目标对象和当前拦截器包装后的插件 Plugin 类代理对象(JDK 动态代理),然后在 pluginAll 方法中再次包装,层层套娃(就像洋葱一样)…

image.png

Executor、StatementHandler、ParameterHandler、ResultSetHandler 通过 pluginAll 最后得到的是若干个拦截器层层包装得到的代理对象!

**所以在执行的时候是先执行最外层(最后配置)的拦截器,一层一层往里面走,直到执行目标对象的方法,然后从目标对象返回,也是从里面网外层访问!**如下:

拦截器 C 执行开始...
拦截器 B 执行开始...
拦截器 A 执行开始...
目标 Executor 执行...
拦截器 A 执行完毕...
拦截器 B 执行完毕...
拦截器 C 执行完毕...

接下来可以看一下 Plugin 类的具体实现,解密到底是怎么包装的。

public class Plugin implements InvocationHandler {
	
    // 目标对象
    private final Object target;
    // 拦截器
    private final Interceptor interceptor;
    // 拦截器的配置拦截策略,拦截哪些类的哪些方法
    private final Map<Class<?>, Set<Method>> signatureMap;

    private Plugin(Object target, Interceptor interceptor, Map<Class<?>, Set<Method>> signatureMap) {
        this.target = target;
        this.interceptor = interceptor;
        this.signatureMap = signatureMap;
    }

    public static Object wrap(Object target, Interceptor interceptor) {
        // 获取拦截策略,对我们在拦截器上使用的注解配置的解析处理
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
        if (interfaces.length > 0) {
            // 创建 JDK 动态代理,代理的目标就是将拦截器包装成的插件对象
            return Proxy.newProxyInstance(
                type.getClassLoader(),
                interfaces,
                new Plugin(target, interceptor, signatureMap));
        }
        return target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            // 调用代理对象的方法就会触发 invoke
            Set<Method> methods = signatureMap.get(method.getDeclaringClass());
            // 判断当前执行的方法是不是在拦截器要拦截的方法
            if (methods != null && methods.contains(method)) {
                // 如果是要拦截的目标,则调用此拦截器的 interceptor 方法,也就是我们自己定义拦截器的核心方法在这里会被执行。
                return interceptor.intercept(new Invocation(target, method, args));
            }
            // 如果不是要拦截的目标,直接放行
            return method.invoke(target, args);
        } catch (Exception e) {
            throw ExceptionUtil.unwrapThrowable(e);
        }
    }

    // 解析自定义拦截器中定义的注解,解析拦截策略
    private static Map<Class<?>, Set<Method>> getSignatureMap(Interceptor interceptor) {
        Intercepts interceptsAnnotation = interceptor.getClass().getAnnotation(Intercepts.class);
        if (interceptsAnnotation == null) {
            throw new PluginException("No @Intercepts annotation was found in interceptor " + interceptor.getClass().getName());      
        }
        // 一个拦截器可以配置多个拦截策略
        Signature[] sigs = interceptsAnnotation.value();
        Map<Class<?>, Set<Method>> signatureMap = new HashMap<Class<?>, Set<Method>>();
        for (Signature sig : sigs) {
            // 解析要拦截的类以及对应的方法,维护在 signatureMap 中
            Set<Method> methods = signatureMap.get(sig.type());
            if (methods == null) {
                methods = new HashSet<Method>();
                signatureMap.put(sig.type(), methods);
            }
            try {
                Method method = sig.type().getMethod(sig.method(), sig.args());
                methods.add(method);
            } catch (NoSuchMethodException e) {
                throw new PluginException("Could not find method on " + sig.type() + " named " + sig.method() + ". Cause: " + e, e);
            }
        }
        return signatureMap;
    }

    // 获取目标对象的所有接口定义,根据在拦截器里面配置的类型进行过滤,用于 JDK 动态代理
    private static Class<?>[] getAllInterfaces(Class<?> type, Map<Class<?>, Set<Method>> signatureMap) {
        Set<Class<?>> interfaces = new HashSet<Class<?>>();
        while (type != null) {
            for (Class<?> c : type.getInterfaces()) {
                if (signatureMap.containsKey(c)) {
                    interfaces.add(c);
                }
            }
            type = type.getSuperclass();
        }
        return interfaces.toArray(new Class<?>[interfaces.size()]);
    }
}
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

作者其他文章

评论(0

抱歉,系统识别当前为高风险访问,暂不支持该操作

    全部回复

    上滑加载中

    设置昵称

    在此一键设置昵称,即可参与社区互动!

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。