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

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

前言


扩展性是衡量软件质量的重要标准,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 接口实例的方法,创建实例后会创建这些实例的代理对象。


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


用户定义插件,并在 xml 配置中注册。

MyBatis 解析 xml 配置,并将插件到 Configuration 中的 PluginChain 实例中。

MyBatis 执行 SQL 时利用 Configuration 中的 PluginChain 创建 Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口实例的代理对象。

Executor、StatementHandler、ParameterHandler、ResultSetHandler 接口方法执行时执行代理对象的方法。

代理对象执行插件中的方法。


自定义 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 插件呢?欢迎留言讨论。


目录
相关文章
|
2月前
|
Java 数据库连接 Maven
使用mybatis插件generator生成实体类,dao层和mapper映射
使用mybatis插件generator生成实体类,dao层和mapper映射
48 0
|
1月前
Mybatis+mysql动态分页查询数据案例——分页工具类(Page.java)
Mybatis+mysql动态分页查询数据案例——分页工具类(Page.java)
22 1
|
16天前
|
SQL Java 数据库连接
【mybatis】第一篇,Springboot中使用插件PageHelper不生效解决方案
【mybatis】第一篇,Springboot中使用插件PageHelper不生效解决方案
|
28天前
|
SQL Java 数据库连接
Mybatis是如何实现分页功能的
Mybatis是如何实现分页功能的
10 0
|
2月前
|
XML 监控 druid
【Java专题_02】springboot+mybatis+pagehelper分页插件+druid数据源详细教程
【Java专题_02】springboot+mybatis+pagehelper分页插件+druid数据源详细教程
|
2月前
|
SQL Java 数据库连接
MyBatis 的 3 种分页方式
MyBatis 的 3 种分页方式
124 1
MyBatis 的 3 种分页方式
|
2月前
|
SQL Java 关系型数据库
|
3月前
|
Java 数据库连接 数据库
【MyBatis】tkMapper 插件
【1月更文挑战第14天】【MyBatis】tkMapper 插件
|
3月前
|
SQL Oracle 关系型数据库
mybatis-3.分页
mybatis-3.分页
|
3月前
|
Java 数据库连接 mybatis
Mybatis之分页插件
【1月更文挑战第5天】 一、分页插件使用步骤 1、添加依赖 2、配置分页插件 二、分页插件的使用 1、开启分页功能 2、分页相关数据 方法一:直接输出 方法二使用PageInfo 常用数据:
50 1