谈谈 MyBatis 的插件,除了分页你可能还有这些使用场景

系列文章目录

这是 MyBatis 源码之旅的第四篇文章,MyBatis 版本号为 3.5.6,源码分析注释已上传到 Github ,前三篇的文章目录如下,建议按照顺序阅读。
  1. MyBatis 初探,使用 MyBatis 简化数据库操作(超详细)
  2. MyBatis Mapper 接口方法执行原理分析
  3. 一条 SQL 是如何在 MyBatis 中执行的

前言

扩展性是衡量软件质量的重要标准,MyBatis 作为一款优秀的持久层框架自然也提供了扩展点,那就是我们今天谈到的插件。MyBaits 的插件拦截内部组件方法的执行,利用插件可以插入自定义的逻辑,例如常用的支持物理分页的 PageHelper 插件。

使用 MyBatis 插件

插件在 MyBatis 中使用接口 Interceptor 表示,MyBatis 本身并未提供任何插件的实现,自定义的插件需要实现接口 Interceptor,示例如下。

public class MyBatisInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // ... 方法执行前插入自定义逻辑
        Object result = invocation.proceed();
        // ... 方法执行后处理结果
        return result;
    }
}

Interceptor 会拦截某些方法的执行,当 MyBatis 内部执行这些方法时就会调用 #intercept 方法,那么 MyBatis 怎么知道调用哪些方法时执行插件的方法呢?这需要用户告诉 MyBatis,使用如下的方式可以指定要拦截的方法。

@Intercepts({
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class MyBatisInterceptor implements Interceptor {
    ...
}

通过在自定义的 Interceptor 类上添加 @Intercepts 注解指定要拦截的方法,@Intercepts 的 value 属性是一个 @Signature 注解类型的数组,这表明同一个插件可以拦截多个方法的执行。@Signature 表示要拦截的方法的签名,需要分别指定要拦截的接口类型、方法名、方法参数。Java 8 开始已经支持重复注解,然而到目前为止 MyBatis 并未进行更新支持。

那么 Interceptor 可以拦截所有的接口方法调用?显然不太可能。Interceptor 可以拦截的接口包括 Executor、StatementHandler、ParameterHandler、ResultSetHandler,这四个接口贯穿 SQL 执行的整个过程,不熟悉的小伙伴可参考前面的文章《一条 SQL 是如何在 MyBatis 中执行的》

定义了插件之后还要告诉 MyBatis 使用我们的插件,这需要向 MyBatis 中的 Configuration 进行注册,如果使用 xml 定义 MyBatis 的配置,可以使用如下的方式进行注册。

<configuration>
    <plugins>
        <plugin interceptor="com.zzuhkp.blog.mybatis.MyBatisInterceptor">
            <property name="customProperty" value="propertyValue"/>
        </plugin>
    </plugins>
</configuration>

其中 property 用来指定 Interceptor 中可以使用的属性,至此我们定义的插件就会在 MyBatis 执行 SQL 时执行。


理解 MyBatis 插件

上面主要是从使用方的角度说明如何自定义插件并向 MyBatis 中注册,下面对 MyBatis 插件的内部实现进行分析。先看插件 Interceptor 的定义。

public interface Interceptor {

  Object intercept(Invocation invocation) throws Throwable;

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

  default void setProperties(Properties properties) {
    // NOP
  }

}

Interceptor 接口中只有一个 #intercept 方法需要重写,该方法有一个 Invocation 类型的参数用于获取拦截的方法信息,包括接口、方法、参数值。

#setProperties 方法则可以接收 xml 中配置的属性。

Interceptor 中还有一个重要的#plugin方法,该方法调用 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) {
            return Proxy.newProxyInstance(
                type.getClassLoader(),
                interfaces,
                new Plugin(target, interceptor, signatureMap));
        }
        return target;
    }
}

可以看到 Plugin 本身就是一个 InvocationHandler,#wrap 方法会获取目标类型的接口,实例化 Plugin 并生成目标类型的代理,当目标类型的方法被调用时就会调用 Plugin 的相关方法,具体如下。

public class Plugin implements InvocationHandler {
    @Override
    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);
        }
    }
}    

当调用目标类型的方法,如 Executor#query 方法时,会转而调用Plugin#invoke 方法,#invoke 方法把参数封装到 Invocation,然后调用我们定义的插件方法。

那么什么时候会生成目标类型的代理?具体又有哪些目标类型会被代理呢?跟踪源码,我们发现 Interceptor#plugin 方法会被如下的地方调用。

public class InterceptorChain {
	// 插件列表
    private final List<Interceptor> interceptors = new ArrayList<>();

	// 生成目标类型的代理,目标方法调用时调用 Interceptor 中的方法
    public Object pluginAll(Object target) {
        for (Interceptor interceptor : interceptors) {
            target = interceptor.plugin(target);
        }
        return target;
    }
}    

我们发现 InterceptorChain 内部保存了插件的列表,并调用 #pluginAll 方法生成目标类型的代理对象,这正是责任链设计模式的一种实现,调用目标方法时,各个插件中的方法会被依次调用。那插件什么时候被添加到 InterceptorChain 中,又什么时候生成哪些目标类型的代理呢?

public class Configuration {

    protected final InterceptorChain interceptorChain = new InterceptorChain();
    
    public void addInterceptor(Interceptor interceptor) {
        interceptorChain.addInterceptor(interceptor);
    }
}

Configuration 中保存了 InterceptorChain 的实例,并提供了添加插件的方法,当解析 xml 配置或手动添加插件时就会保存插件到 InterceptorChain 中。再看什么时候创建代理对象。

public class Configuration {
    public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
        ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
        // 创建 ParameterHandler 的代理对象
        parameterHandler = (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
        return parameterHandler;
    }

    public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
                                                ResultHandler resultHandler, BoundSql boundSql) {
        ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
        // 创建 ResultSetHandler 的代理对象
        resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
        return resultSetHandler;
    }

    public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
        // 创建 StatementHandler 的代理对象
        statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
        return statementHandler;
    }
    
    public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
        ... 省略实例化 Executor 的代码
        // 创建 Executor 的代理对象
        executor = (Executor) interceptorChain.pluginAll(executor);
        return executor;
    }        
}

Confuguration 提供了实例化 Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口实例的方法,创建实例后会创建这些实例的代理对象。

总结插件的执行流程如下。

  1. 用户定义插件,并在 xml 配置中注册。
  2. MyBatis 解析 xml 配置,并将插件到 Configuration 中的 PluginChain 实例中。
  3. MyBatis 执行 SQL 时利用 Configuration 中的 PluginChain 创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口实例的代理对象。
  4. Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口方法执行时执行代理对象的方法。
  5. 代理对象执行插件中的方法。

自定义 MyBatis 插件

MyBatis 中的插件常用的是分页,分页有开源框架 PageHelper,MyBatis-Plus 中可以使用 PaginationInterceptor 或 MybatisPlusInterceptor 作为分页插件。除了分页在日常开发中可能还有下面的场景需要使用 MyBatis 插件。

自动设置字段值到数据库记录

通常,我们会记录某一条数据库记录的创建人、创建时间、修改人、修改时间。如果手动在插入或者更新前设置,那么设置这些字段的代码将遍布项目中的各个地方。这个时候很容易考虑到的是使用 AOP 处理,因为我们使用的是 MyBatis 作为持久层框架,我们可以通过插件设置当前登录人及时间到记录中。

假定所有的数据库表对应的实体类都有如下的父类。

public class BaseEntity {
    // 创建时间
    private Date gmtCreate;
    // 修改时间
    private Date gmtModified;
    // 创建人
    private String createBy;
    // 修改人
    private String updateBy;
}

设置登录人及时间到数据库记录的插件的实现如下。

@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class MybatisFiledSetInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement statement = (MappedStatement) args[0];
        SqlCommandType sqlCommandType = statement.getSqlCommandType();
        Object param = args[1];
        if (sqlCommandType == SqlCommandType.INSERT) {
        	// insert 语句设置创建人、创建时间
            this.setCreateProperty(param);
        } else if (sqlCommandType == SqlCommandType.UPDATE) {
        	// update 语句设置更新人、更新时间
            this.setUpdateProperty(param);
        }

        return invocation.proceed();
    }

	// 设置创建人及创建时间
    private void setCreateProperty(Object param) {
        if (param instanceof Map) {
            for (Object value : ((Map<String, Object>) param).values()) {
                this.doSetCreateProperty(value);
            }
        }
        this.doSetCreateProperty(param);
    }
    private void doSetCreateProperty(Object obj) {
        if (obj instanceof BaseEntity) {
            BaseEntity entity = (BaseEntity) obj;
            if (entity.getGmtCreate() == null) {
                Date now = new Date();
                entity.setGmtCreate(now);
            }
            if (StringUtils.isBlank(entity.getCreateBy())) {
                entity.setCreateBy(RequestHolderUtil.getCurrentUser() == null ? "System" : RequestHolderUtil.getCurrentUser().getAccountName());
            }
        }
    }
 
	// 设置更新人及更新时间
    private void setUpdateProperty(Object param) {
        if (param instanceof Map) {
            for (Object value : ((Map<String, Object>) param).values()) {
                this.doSetUpdateProperty(value);
            }
        }
        this.doSetUpdateProperty(param);
    }
    private void doSetUpdateProperty(Object obj) {
        if (obj instanceof BaseEntity) {
            BaseEntity entity = (BaseEntity) obj;
            if (entity.getGmtModified() == null) {
                Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
                entity.setGmtModified(now);
            }
            if (StringUtils.isBlank(entity.getUpdateBy())) {
                entity.setUpdateBy(RequestHolderUtil.getCurrentUser() == null ? "System" : RequestHolderUtil.getCurrentUser().getAccountName());
            }
        }
    }
}

这里拦截了 Executor#update 方法的执行,当使用 MyBatis 执行插入或更新语句时会调用该方法,MyBatis 有可能把参数封装到 Map 中,因此对 Map 做了特殊处理。如果参数为 BaseEntity ,则设置相应的字段到 BaseEntity 中。另外由于 BaseEntity 包含了创建和更新信息,有的数据库记录可能并不需要更新,或只需要记录创建时间,遵循接口隔离原则,可以把 BaseEntity 拆分成接口处理。

数据库字段加密

另一种场景是数据库字段加密,如用户的密码、姓名、手机号、地址等敏感信息,为了避免数据库密码泄露时暴露这些信息,存入数据库时需要进行加密,从数据库取数据时需要解密。可以在操作数据库前后手动加密或者解密,然而更省力的自然是通过 Mybatis 插件自动加密或解密。

我们可以创建一个用于字段的注解 @SensitiveField,当实体类字段上存在这个注解时,在插入或者更新数据库前使用自定义的 Encryptor 类对这个字段进行加密,查询数据库后使用自定义的 Decryptor 对包含这个注解的字段进行解密。

用于加密的 MyBatis 插件如下。

@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class MyBatisEncryptionInterceptor implements Interceptor {

    private Encryptor encryptor = new Encryptor();
    private Decryptor decryptor = new Decryptor();
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object param = invocation.getArgs()[1];
        // 执行更新前加密参数
        this.handleEncrypt(param);
        Object result = invocation.proceed();
        // 执行更新后解密参数,避免后续使用
        this.handleDecrypt(param);
        return result;
    }
    
	// 处理数据库字段加密
    private void handleEncrypt(Object param) {
        if (param == null) {
            return;
        }
        if (param instanceof Map) {
            // 去重,避免重复加密
            for (Object item : new HashSet<>(((Map<String, Object>) param).values())) {
                encryptor.encrypt(item);
            }
            return;
        }
        encryptor.encrypt(param);
    }

	// 处理字段解密
    private void handleDecrypt(Object param) {
        if (param == null) {
            return;
        }
        if (param instanceof Map) {
            // 去重,避免重复解密
            for (Object item : new HashSet<>(((Map<String, Object>) param).values())) {
                decryptor.decrypt(item);
            }
            return;
        }
        decryptor.decrypt(param);
    }
}

加密插件拦截 Executor#update 方法,当插入或更新时会执行该方法,需要留意的是 Map 中的值可能是重复的,这是因为 MyBatis 会把不同 key 存入同一个对象,因此需要去重,避免重复加密,另外加密之后还要进行解密,避免后续使用未加密的字段。

解密插件如下。

@Intercepts({@Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query",
                args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class MyBatisDecryptionInterceptor implements Interceptor {

    private Decryptor decryptor = new Decryptor();
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();
        // 执行数据库字段解密
        List<?> list = (List<?>) result;
        if (!CollectionUtils.isEmpty(list)) {
            for (Object item : list) {
                decryptor.decrypt(item);
            }
        }
        return result;
    }
}

解密插件拦截了 Executor#query 方法,该方法会返回一个 List,我们直接对 List 中需要解密的字段即可。


总结

本篇先介绍了 MyBatis 插件的定义及注册,然后对 MyBatis 内部插件的实现进行了介绍,最后还举了两个自定义插件的例子。你们的项目中还有哪些场景还会使用 MyBatis 插件呢?欢迎留言讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

大鹏cool

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

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

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

打赏作者

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

抵扣说明:

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

余额充值