MyBatis 插件介绍及应用
MyBatis 是一个持久层框架,它允许开发者自定义 SQL 语句并将其映射到 Java 对象中。MyBatis 提供了一种灵活的数据库操作方式,但随着项目的复杂度增加,一些通用功能如分页、缓存、事务管理等可能需要重复编写。为了解决这个问题,MyBatis 提供了插件机制,允许开发者扩展 MyBatis 的功能,实现自定义逻辑。
一、MyBatis 插件概述
MyBatis 插件是 MyBatis 框架的扩展点,它们可以拦截 MyBatis 的核心处理过程,包括执行器、参数处理器、结果处理器等。通过编写插件,开发者可以在不修改 MyBatis 核心代码的情况下,增加新的功能或改变 MyBatis 的行为。
1.1 插件的作用
- 拦截器:在 MyBatis 执行 SQL 之前或之后执行自定义逻辑。
- 扩展功能:实现 MyBatis 未提供的功能,如分页、性能监控等。
- 自定义 SQL:通过插件机制,可以自定义 SQL 片段,提高代码的复用性。
1.2 插件的工作原理
MyBatis 插件通过使用 Java 的代理机制实现。开发者需要实现 MyBatis 提供的 Interceptor
接口,并重写 intercept
方法。在 intercept
方法中,可以定义拦截逻辑。
1.3 拦截四种核心组件
MyBatis所允许拦截的⽅法如下:
Executor:执⾏器 (update、query、commit、rollback等⽅法),负责SQL语句的执行和事务管理;
StatementHandler:SQL语法构建器(prepare、parameterize、batch、updates query等⽅ 法),处理具体的SQL语句,包括预编译和参数设置等;
ParameterHandler:参数处理器 (getParameterObject、setParameters⽅法),负责将用户传递的参数转换成JDBC可识别的参数;
ResultSetHandler:结果集处理器 (handleResultSets、handleOutputParameters等⽅法),负责将JDBC返回的结果集转换成用户所需的对象或集合;
二、MyBatis 插件开发
开发 MyBatis 插件需要对 MyBatis 的工作流程有深入的理解。下面是一个简单的插件开发示例。
会针对四种核心组件分别实现一个插件。
2.1Executor 拦截器实现
2.1.1 Query 拓展点
- 用途:可以在查询操作执行前后添加逻辑,如记录查询时间、进行查询缓存等。
- 拦截方法:
query
功能:打印查询sql耗时,以及结果集行数。
package com.company.oneday.plugin;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
@Intercepts({@Signature(type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})})
public class ExecutorQueryPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.currentTimeMillis();
Object result = invocation.proceed(); // 执行原方法
long end = System.currentTimeMillis();
System.out.println("查询时间: " + (end - start) + " ms");
// 如果查询结果是一个 List,可以打印查询到的行数
if (result instanceof List<?>) {
List<?> list = (List<?>) result;
System.out.println("查询行数: " + list.size());
}
return result; // 返回原方法结果
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}
2.1.2 Update 拓展点
- 用途:可以在更新(插入、修改、删除)操作执行前后添加逻辑,如统计影响行数、记录变更日志等。
- 拦截方法:
update
package com.company.oneday.plugin;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.springframework.stereotype.Component;
import java.util.Properties;
@Component
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class ExecutorUpdatePlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 记录操作开始时间
long start = System.currentTimeMillis();
// 执行 update 操作
Object result = invocation.proceed(); // 执行目标方法
// 记录操作结束时间
long end = System.currentTimeMillis();
// 计算操作耗时
long timeElapsed = end - start;
// 获取影响的行数
int affectedRows = (Integer) result;
// 获取 MappedStatement 来获取相关的信息,如 SQL 语句和参数
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 记录变更日志
recordChangeLog(mappedStatement, parameter, affectedRows);
// 打印消耗时间和影响行数
System.out.println(String.format("更新操作耗时: %d ms, 受影响的行数: %d", timeElapsed, affectedRows));
return result; // 返回操作影响的行数
}
private void recordChangeLog(MappedStatement mappedStatement, Object parameter, int affectedRows) {
// 模拟记录变更日志的逻辑
// 这里可以根据实际需要,将变更信息写入日志系统或者存储起来
String sql = mappedStatement.getBoundSql(parameter).getSql();
sql = sql.replaceAll("\n", " ").replaceAll(" +", " ").toLowerCase();
System.out.println("sql 日志打印: " + sql);
// 可以在这里添加更多的日志记录逻辑,如记录操作的用户、时间戳等
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}
2.1.3 Commit 拓展点
- 用途:可以在事务提交时执行额外的操作,如记录事务提交日志、执行某些后置操作等。
- 拦截方法:
commit
package com.company.oneday.plugin;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.util.Properties;
@Component
@Intercepts({@Signature(type = Executor.class, method = "commit", args = {boolean.class})})
public class ExecutorCommitPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 记录事务提交前的时间
long start = System.currentTimeMillis();
// 执行 commit 操作
Object result = invocation.proceed(); // 执行目标方法
// 记录事务提交后的时间
long end = System.currentTimeMillis();
// 计算事务提交耗时
long timeElapsed = end - start;
// 打印事务提交耗时
System.out.println("事务提交时间: " + timeElapsed + " ms");
// 记录事务提交日志
recordTransactionCommitLog();
// 执行后置操作
performPostActions();
return result; // 返回事务提交操作的结果
}
private void recordTransactionCommitLog() {
// 模拟记录事务提交日志的逻辑
// 这里可以根据实际需要,将事务提交信息写入日志系统或者存储起来
System.out.println("事务已经提交成功.");
}
private void performPostActions() {
// 模拟执行后置操作的逻辑
// 后置操作可以是清理缓存、发送通知、统计信息等
System.out.println("事务提交成功后置通知.");
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}
2.1.4 Rollback 拓展点
- 用途:在事务回滚时执行逻辑,如记录回滚原因、清理资源等。
- 拦截方法:
rollback
package com.company.oneday.plugin;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.util.Properties;
@Component
@Intercepts({@Signature(type = Executor.class, method = "rollback", args = {boolean.class})})
public class ExecutorRollbackPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
System.out.println(1111);
// 执行回滚操作
return invocation.proceed();
} catch (Exception t) {
// 记录回滚原因
recordRollbackReason(t);
throw t;
}
}
private void recordRollbackReason(Throwable t) {
// 实际应用中,这里应将异常信息记录到日志文件或数据库中
System.err.println("事务回滚原因: " + t.getMessage());
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}
2.2 StatementHandler 拦截器实现
prepare
方法:- 作用:用于准备
Statement
对象。在这个方法中,会创建PreparedStatement
对象,并且可能涉及动态 SQL 的解析和参数的预处理。 - 拓展:可以修改 SQL 语句以添加日志、性能监控、防止 SQL 注入、实现分页逻辑等。
- 作用:用于准备
parameterize
方法:- 作用:用于处理参数对象,将参数与 SQL 语句中的占位符进行绑定。
- 拓展:可以修改参数绑定逻辑,例如使用自定义的类型处理器(Type Handler)或者在参数绑定前后添加额外的处理。
batch
方法:- 作用:用于执行批量更新操作。在这个方法中,会将多个 SQL 语句打包在一起执行,以提高性能。
- 拓展:可以监控批量操作的执行情况,或者对批量参数进行预处理。
update
方法:- 作用:用于执行插入、更新或删除操作。在这个方法中,会执行
Statement
对象以修改数据库中的数据。 - 拓展:可以统计影响行数、记录操作日志、在执行前后添加事务控制逻辑等。
- 作用:用于执行插入、更新或删除操作。在这个方法中,会执行
query
方法:- 作用:用于执行查询操作。在这个方法中,会执行
Statement
对象以获取查询结果,并将其映射到 Java 对象中。 - 拓展:可以实现分页查询、缓存查询结果、修改结果集处理逻辑、添加查询性能监控等。
- 作用:用于执行查询操作。在这个方法中,会执行
getBoundSql
方法:- 作用:用于获取
BoundSql
对象,该对象包含了 SQL 语句、参数信息和额外的上下文信息。 - 拓展:可以修改
BoundSql
中的 SQL 语句或参数,或者添加额外的上下文信息。
- 作用:用于获取
- 结果集处理器(
ResultSetHandler
):- 作用:用于处理
ResultSet
对象,将 SQL 查询的结果集映射到 Java 对象。 - 拓展:可以自定义结果集的映射逻辑,实现复杂的对象映射关系,或者在结果集处理前后添加额外的处理。
- 作用:用于处理
2.2.1 prepare 拓展点
- 用途:在事务回滚时执行逻辑,如记录回滚原因、清理资源等。
- 拦截方法:
rollback
package com.company.oneday.plugin.statement;
import com.alibaba.fastjson.annotation.JSONField;
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.ExecutorException;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMap;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
@Component
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class StatementHandlerPreparePlugin implements Interceptor {
// 默认的方言类型,可以根据需要进行扩展
private static final String DIALECT_MYSQL = "mysql";
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);
// 分离代理对象链(由于目标类可能被多个拦截器拦截,从而形成多次代理,通过下面的两次操作可以分离出最原始的的目标类)
while (metaStatementHandler.hasGetter("h")) {
Object object = metaStatementHandler.getValue("h");
metaStatementHandler = SystemMetaObject.forObject(object);
}
// 获取到当前的映射语句对象(MappedStatement)
MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement");
// 只对需要分页的查询进行拦截
if (mappedStatement.getId().endsWith("ByPage")) {
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
PaginationParam paginationParam = null;
// 获取分页参数
//兼容下面两种情况,其他的遇到了再补充
if (boundSql.getParameterObject() instanceof PaginationParam) {
paginationParam = (PaginationParam) boundSql.getParameterObject();
} else if (boundSql.getParameterObject() instanceof MapperMethod.ParamMap) {
MapperMethod.ParamMap map = (MapperMethod.ParamMap) boundSql.getParameterObject();
List<PaginationParam> collect = (List<PaginationParam>) map.values().stream()
.filter(e -> e instanceof PaginationParam)
.collect(Collectors.toList());
if(!CollectionUtils.isEmpty(collect)) {
paginationParam = collect.get(0);
}
} else {
}
String pageSql = buildPageSql(sql, paginationParam);
pageSql = pageSql.replaceAll("\n", " ").replaceAll(" +", " ").toLowerCase();
System.out.println("sql:" + pageSql);
// 通过反射设置当前boundSql对应的sql为分页sql
Field sqlField = boundSql.getClass().getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, pageSql);
// 采用物理分页后,就不需要mybatis的内存分页了,所以这里将这两个参数都置为null即可
metaStatementHandler.setValue("delegate.rowBounds.offset", RowBounds.DEFAULT.getOffset());
metaStatementHandler.setValue("delegate.rowBounds.limit", RowBounds.DEFAULT.getLimit());
}
// 继续执行原始方法
return invocation.proceed();
}
private String buildPageSql(String sql, PaginationParam paginationParam) {
if(paginationParam != null && paginationParam.getOffset() != null && paginationParam.getLimit() != null) {
// 这里只提供了一个简单的MySQL分页示例,实际情况可能需要根据数据库类型动态构建SQL
sql = sql + " LIMIT " + paginationParam.getOffset() + "," + paginationParam.getLimit();
}
return sql;
}
@Override
public Object plugin(Object target) {
// 创建代理对象
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 处理插件属性(如果有的话)
}
// 分页参数类
@Data
public static class PaginationParam {
@TableField(exist = false)
private Integer offset; // 起始行数
@TableField(exist = false)
private Integer limit; // 每页显示的数量
}
}
2.3 ParameterHandler 拦截器实现
getParameterObject
方法:- 作用:获取传递给
StatementHandler
的参数对象。 - 拓展:可以在这个方法中修改参数对象,例如添加额外的参数、修改参数值或替换参数对象。
- 作用:获取传递给
setParameters
方法:- 作用:将参数对象的值设置到
Statement
对象的 SQL 占位符中。 - 拓展:可以修改参数的设置逻辑,例如使用自定义的类型处理器(Type Handler)或在参数设置前后添加额外的处理。
- 作用:将参数对象的值设置到
2.3.1 setParameters 拓展点
package com.company.oneday.plugin.parameterHandler;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.MybatisDefaultParameterHandler;
import org.apache.ibatis.executor.parameter.ParameterHandler;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.Properties;
@Component
@Intercepts({@Signature(type = ParameterHandler.class, method = "setParameters", args = {PreparedStatement.class})})
public class ParameterHandlerSetParametersPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
PreparedStatement statement = (PreparedStatement) invocation.getArgs()[0];
MybatisDefaultParameterHandler parameterHandler = (MybatisDefaultParameterHandler) invocation.getTarget();
// 打印参数对象
Object parameter = parameterHandler.getParameterObject();
System.out.println("Parameter object before setting: " + JSON.toJSONString(parameter));
// 修改参数对象
// ........
// 继续执行参数设置
Object result = invocation.proceed();
// 再次打印参数对象,可能已被修改
System.out.println("Parameter object after setting: " + JSON.toJSONString(parameter));
return result;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}
2.4 ResultSetHandler 拦截器实现
handleResultSets
方法:- 作用:处理
Statement
对象执行后返回的结果集。 - 拓展:可以修改结果集的处理逻辑,例如实现自定义的结果集映射、过滤特定列的数据、实现懒加载等。
- 作用:处理
handleOutputParameters
方法:- 作用:处理存储过程调用后的输出参数。
- 拓展:可以对输出参数进行特殊处理,比如转换为特定的 Java 类型。
2.4.1 handleResultSets拓展点
实现对查询结果中的密码password进行MD5加密。
处理器实现:
package com.company.oneday.plugin.resultSetHandler;
import com.company.oneday.entity.User;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import java.sql.CallableStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
public class EncryptingResultSetHandler implements ResultSetHandler {
private final ResultSetHandler resultSetHandler;
public EncryptingResultSetHandler(ResultSetHandler resultSetHandler) {
this.resultSetHandler = resultSetHandler;
}
@Override
public List<Object> handleResultSets(Statement stmt) throws SQLException {
// 使用委托对象处理结果集
List<Object> result = this.resultSetHandler.handleResultSets(stmt);
// 假设我们有一个User对象,并且知道密码字段名为"password"
// 对密码进行“加密”操作(这里只是示例,实际应该是解密)
if (result instanceof List) {
List<?> resultList = (List<?>) result;
for (Object item : resultList) {
if (item instanceof User) {
User user = (User) item;
String encryptedPassword = encryptPassword(user.getPassword());
user.setPassword(encryptedPassword);
}
}
}
return result;
}
private String encryptPassword(String password) {
// 这里应该是你的加密逻辑,为了演示,我们使用一个简单的替换逻辑
return DigestUtils.md5Hex(password);
}
@Override
public <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException {
return null;
}
@Override
public void handleOutputParameters(CallableStatement cs) throws SQLException {
}
// 其他方法...
}
拦截器实现:
package com.company.oneday.plugin.resultSetHandler;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Properties;
@Component
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})})
public class ResultSetHandlerHandleResultSetsPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Statement stmt = (Statement) invocation.getArgs()[0];
// 创建自定义的 EncryptingResultSetHandler
EncryptingResultSetHandler customResultSetHandler = new EncryptingResultSetHandler((ResultSetHandler) invocation.getTarget());
// Object result = invocation.proceed();
// 使用自定义的 EncryptingResultSetHandler 重新处理结果集
return customResultSetHandler.handleResultSets(stmt);
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以为插件配置属性
}
}