前言
扩展性是衡量软件质量的重要标准,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 插件呢?欢迎留言讨论。