插件开发
插件是 MyBatis 提供的一种强大的扩展机制,允许开发者在 SQL 语句执行过程中的特定点进行拦截,实现对 MyBatis 核心行为的定制。通过插件,可以实现 SQL 日志记录、性能监控、分页、数据权限控制等功能。
插件机制原理
MyBatis 的四大核心对象
MyBatis 在执行 SQL 语句时,主要涉及四个核心对象。插件可以拦截这些对象的方法调用:
四大核心对象详解
Executor(执行器)
执行器是 MyBatis 的核心,负责 SQL 语句的执行、缓存管理和事务处理。MyBatis 提供三种执行器类型:
- SimpleExecutor:每执行一次 update 或 select,就开启一个 Statement 对象,用完立刻关闭
- ReuseExecutor:执行 update 或 select,以 SQL 作为 key 查找 Statement 对象,存在就使用,不存在就创建,用完后不关闭,放置在 Map 中供下一次使用
- BatchExecutor:执行 update,将所有 SQL 都添加到批处理中,等待统一执行
StatementHandler(语句处理器)
负责创建 Statement 对象,设置参数,执行 SQL 语句。它封装了 JDBC Statement 的操作,是 MyBatis 与 JDBC 交互的关键组件。
ParameterHandler(参数处理器)
负责将用户传递的参数转换成 JDBC Statement 所需要的参数。它处理 #{} 占位符的参数设置。
ResultSetHandler(结果集处理器)
负责将 JDBC 返回的 ResultSet 结果集转换成 Java 对象列表。它处理结果映射、嵌套映射等逻辑。
可拦截的方法
| 核心对象 | 可拦截方法 | 说明 |
|---|---|---|
| Executor | update | 执行 insert/update/delete |
| query | 执行查询 | |
| flushStatements | 刷新批处理语句 | |
| commit | 提交事务 | |
| rollback | 回滚事务 | |
| getTransaction | 获取事务对象 | |
| close | 关闭执行器 | |
| isClosed | 判断是否已关闭 | |
| StatementHandler | prepare | 预编译 SQL |
| parameterize | 设置参数 | |
| batch | 批量执行 | |
| update | 执行更新 | |
| query | 执行查询 | |
| ParameterHandler | getParameterObject | 获取参数对象 |
| setParameters | 设置参数 | |
| ResultSetHandler | handleResultSets | 处理结果集 |
| handleOutputParameters | 处理存储过程输出参数 |
Interceptor 接口
所有自定义插件都必须实现 org.apache.ibatis.plugin.Interceptor 接口:
public interface Interceptor {
/**
* 拦截目标对象方法的执行
* @param invocation 封装了目标对象、目标方法和方法参数
* @return 方法执行结果
*/
Object intercept(Invocation invocation) throws Throwable;
/**
* 为目标对象创建代理对象
* @param target 目标对象
* @return 代理对象
*/
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
/**
* 设置插件属性(从配置文件中读取)
* @param properties 属性配置
*/
default void setProperties(Properties properties) {
// 默认空实现
}
}
intercept 方法
这是核心方法,所有的拦截逻辑都在这里实现。Invocation 对象包含以下内容:
public class Invocation {
private final Object target; // 被拦截的目标对象
private final Method method; // 被拦截的方法
private final Object[] args; // 方法参数
// 执行原方法
public Object proceed() throws InvocationTargetException, IllegalAccessException {
return method.invoke(target, args);
}
}
plugin 方法
用于为目标对象创建代理。默认实现使用 Plugin.wrap() 方法,它会自动判断目标对象是否需要被代理(根据 @Intercepts 注解配置)。
setProperties 方法
用于接收配置文件中定义的插件属性:
<plugin interceptor="com.example.SqlLogPlugin">
<property name="slowSqlThreshold" value="1000"/>
</plugin>
@Intercepts 和 @Signature 注解
注解说明
@Intercepts 注解用于标记拦截器要拦截哪些方法,包含一个 @Signature 数组:
@Intercepts({
@Signature(
type = Executor.class, // 要拦截的四大对象类型
method = "query", // 要拦截的方法名
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} // 方法参数类型
)
})
public class MyPlugin implements Interceptor {
// ...
}
@Signature 参数详解
| 参数 | 说明 | 示例 |
|---|---|---|
type | 要拦截的四大对象类型 | Executor.class |
method | 要拦截的方法名 | "query" |
args | 方法参数类型数组(用于区分重载方法) | {MappedStatement.class, Object.class} |
拦截 Executor.query 方法
Executor.query 有两个重载方法,需要明确指定参数类型:
// 方法签名1
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
// 方法签名2(带 CacheKey)
<E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql)
// 拦截方法签名1
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
// 拦截方法签名2
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)
实战示例
示例一:SQL 执行时间监控插件
这是一个记录每条 SQL 执行时间的插件,可用于性能分析和慢 SQL 定位:
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 java.util.Properties;
@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 SqlTimePlugin implements Interceptor {
// 慢 SQL 阈值(毫秒)
private int slowSqlThreshold = 1000;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取 MappedStatement 对象
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
// 获取 SQL ID
String sqlId = mappedStatement.getId();
// 记录开始时间
long startTime = System.currentTimeMillis();
try {
// 执行原方法
return invocation.proceed();
} finally {
// 计算执行时间
long endTime = System.currentTimeMillis();
long time = endTime - startTime;
// 输出日志
StringBuilder sb = new StringBuilder();
sb.append("SQL: ").append(sqlId);
sb.append(" | 耗时: ").append(time).append("ms");
if (time > slowSqlThreshold) {
sb.append(" [慢SQL]");
}
System.out.println(sb.toString());
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 从配置中读取阈值
String threshold = properties.getProperty("slowSqlThreshold");
if (threshold != null) {
this.slowSqlThreshold = Integer.parseInt(threshold);
}
}
}
配置:
<plugins>
<plugin interceptor="com.example.mybatis.plugin
if (threshold != null) {
slowSqlThreshold = Integer.parseInt(threshold);
}
}
}
配置:
<plugins>
<plugin interceptor="com.example.mybatis.plugin.SqlTimePlugin">
<property name="slowSqlThreshold" value="500"/>
</plugin>
</plugins>
运行结果:
SQL: com.example.mapper.UserMapper.selectById | 耗时: 15ms
SQL: com.example.mapper.UserMapper.selectAll | 耗时: 520ms [慢SQL]
SQL: com.example.mapper.UserMapper.insert | 耗时: 8ms
示例二:SQL 日志打印插件
这个插件可以打印完整的可执行 SQL 语句,方便调试:
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.type.TypeHandlerRegistry;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
@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 SqlLogPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
// 获取配置
Configuration configuration = mappedStatement.getConfiguration();
// 获取 BoundSql
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
// 获取 SQL 语句
String sql = showSql(configuration, boundSql);
System.out.println("执行的 SQL: " + sql);
return invocation.proceed();
}
/**
* 将 SQL 中的 ? 替换为实际参数值
*/
private String showSql(Configuration configuration, BoundSql boundSql) {
// 获取 SQL 语句
String sql = boundSql.getSql();
if (sql == null || sql.isEmpty()) {
return "";
}
// 获取参数映射
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
Object parameterObject = boundSql.getParameterObject();
// 如果没有参数,直接返回
if (parameterMappings == null || parameterMappings.isEmpty()) {
return sql;
}
// 获取类型处理器注册表
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
// 替换参数
for (ParameterMapping parameterMapping : parameterMappings) {
if (parameterMapping.getMode() != null &&
!parameterMapping.getMode().equals(ParameterMapping.Mode.OUT)) {
// 获取参数值
String propertyName = parameterMapping.getProperty();
Object value;
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
// 替换 SQL 中的 ?
sql = sql.replaceFirst("\\?", getParameterValue(value));
}
}
return sql;
}
/**
* 获取参数值的字符串表示
*/
private String getParameterValue(Object obj) {
if (obj == null) {
return "null";
}
if (obj instanceof String) {
return "'" + obj + "'";
}
if (obj instanceof Date) {
DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.CHINA);
return "'" + dateFormat.format((Date) obj) + "'";
}
return obj.toString();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
运行结果:
执行的 SQL: SELECT * FROM user WHERE id = 1
执行的 SQL: SELECT * FROM user WHERE username = '张三' AND status = 1
执行的 SQL: INSERT INTO user (username, password, email) VALUES ('test', '123456', '[email protected]')
示例三:分页插件简化版
这是一个简化版的分页插件,展示了如何拦截 StatementHandler 修改 SQL:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import java.sql.Connection;
import java.util.Properties;
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)
})
public class PaginationPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 获取 MetaObject,可以访问对象的属性
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
// 获取 MappedStatement
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// 获取 SQL ID
String sqlId = mappedStatement.getId();
// 判断是否需要分页(根据命名约定)
if (sqlId.endsWith("ByPage")) {
// 获取 BoundSql
BoundSql boundSql = statementHandler.getBoundSql();
// 获取原始 SQL
String originalSql = boundSql.getSql();
// 获取分页参数(假设参数对象中有 pageNum 和 pageSize 属性)
Object parameterObject = boundSql.getParameterObject();
if (parameterObject != null) {
MetaObject paramMetaObject = SystemMetaObject.forObject(parameterObject);
Integer pageNum = (Integer) paramMetaObject.getValue("pageNum");
Integer pageSize = (Integer) paramMetaObject.getValue("pageSize");
if (pageNum != null && pageSize != null) {
// 计算偏移量
int offset = (pageNum - 1) * pageSize;
// 添加分页 SQL
String newSql = originalSql + " LIMIT " + pageSize + " OFFSET " + offset;
// 替换 SQL
metaObject.setValue("delegate.boundSql.sql", newSql);
}
}
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
示例四:数据权限插件
这个插件展示了如何在查询时自动添加数据权限过滤条件:
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.lang.reflect.Field;
import java.util.Properties;
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)
})
public class DataPermissionPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取参数
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
// 获取 BoundSql
BoundSql boundSql = ms.getBoundSql(parameter);
// 获取原始 SQL
String originalSql = boundSql.getSql();
// 获取当前用户的数据权限(这里简化处理,实际应从上下文获取)
String dataPermission = getCurrentUserDataPermission();
// 添加数据权限条件
String newSql = addDataPermissionCondition(originalSql, dataPermission);
// 创建新的 BoundSql
BoundSql newBoundSql = new BoundSql(
ms.getConfiguration(),
newSql,
boundSql.getParameterMappings(),
parameter
);
// 复制附加参数
for (ParameterMapping mapping : boundSql.getParameterMappings()) {
String prop = mapping.getProperty();
if (boundSql.hasAdditionalParameter(prop)) {
newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
}
}
// 创建新的 MappedStatement
MappedStatement newMs = copyMappedStatement(ms, newBoundSql);
// 执行新的查询
return invocation.getTarget().getClass()
.getMethod("query", MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class)
.invoke(invocation.getTarget(), newMs, parameter, rowBounds, resultHandler);
}
/**
* 获取当前用户的数据权限
*/
private String getCurrentUserDataPermission() {
// 实际应用中应从安全上下文或 Session 中获取
return "department_id = 1";
}
/**
* 添加数据权限条件
*/
private String addDataPermissionCondition(String sql, String condition) {
// 简化处理:在 WHERE 子句中添加条件
if (sql.toUpperCase().contains("WHERE")) {
return sql + " AND " + condition;
} else {
return sql + " WHERE " + condition;
}
}
/**
* 复制 MappedStatement
*/
private MappedStatement copyMappedStatement(MappedStatement ms, BoundSql newBoundSql) {
MappedStatement.Builder builder = new MappedStatement.Builder(
ms.getConfiguration(),
ms.getId(),
new BoundSqlSqlSource(newBoundSql),
ms.getSqlCommandType()
);
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
builder.keyProperty(String.join(",", ms.getKeyProperties()));
}
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.resultSetType(ms.getResultSetType());
builder.cache(ms.getCache());
builder.flushCacheRequired(ms.isFlushCacheRequired());
builder.useCache(ms.isUseCache());
return builder.build();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
/**
* 自定义 SqlSource
*/
private static class BoundSqlSqlSource implements SqlSource {
private final BoundSql boundSql;
public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
}
插件开发最佳实践
1. 合理选择拦截点
不同的需求应选择不同的拦截对象:
| 需求场景 | 推荐拦截对象 | 推荐拦截方法 |
|---|---|---|
| SQL 日志记录 | Executor | query, update |
| 执行时间统计 | Executor | query, update |
| 分页处理 | StatementHandler | prepare |
| 参数处理 | ParameterHandler | setParameters |
| 结果处理 | ResultSetHandler | handleResultSets |
| 数据权限 | Executor | query, update |
2. 避免重复拦截
使用 Plugin.wrap() 时,会自动判断是否需要创建代理。如果目标对象不在拦截范围内,会直接返回原对象:
@Override
public Object plugin(Object target) {
// 只有目标对象匹配时才创建代理
return Plugin.wrap(target, this);
}
3. 获取真实对象
有时候需要获取被代理的真实对象:
/**
* 获取真实对象(剥离代理)
*/
public static Object getRealTarget(Object target) {
while (Proxy.isProxyClass(target.getClass())) {
InvocationHandler handler = Proxy.getInvocationHandler(target);
if (handler instanceof Plugin) {
try {
Field field = Plugin.class.getDeclaredField("target");
field.setAccessible(true);
target = field.get(handler);
} catch (Exception e) {
break;
}
} else {
break;
}
}
return target;
}
4. 插件执行顺序
当配置多个插件时,执行顺序按照配置顺序:
<plugins>
<!-- 先执行 -->
<plugin interceptor="com.example.Plugin1"/>
<!-- 后执行 -->
<plugin interceptor="com.example.Plugin2"/>
</plugins>
执行顺序:Plugin2 -> Plugin1 -> 原方法 -> Plugin1 后置 -> Plugin2 后置
5. 性能优化
插件会在每次方法调用时执行,要注意性能影响:
// 不推荐:每次都进行字符串操作
@Override
public Object intercept(Invocation invocation) throws Throwable {
String sql = boundSql.getSql();
sql = sql.replaceAll("\\s+", " "); // 性能消耗
return invocation.proceed();
}
// 推荐:只在必要时进行处理
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 先判断是否需要处理
if (!needProcess(invocation)) {
return invocation.proceed();
}
// 再执行处理逻辑
return doProcess(invocation);
}
6. 线程安全
插件实例是单例的,必须保证线程安全:
// 不安全:使用成员变量存储请求相关数据
public class UnsafePlugin implements Interceptor {
private String currentSqlId; // 线程不安全
@Override
public Object intercept(Invocation invocation) throws Throwable {
currentSqlId = getSqlId(invocation); // 多线程时会覆盖
return invocation.proceed();
}
}
// 安全:使用局部变量或 ThreadLocal
public class SafePlugin implements Interceptor {
private static final ThreadLocal<String> sqlIdHolder = new ThreadLocal<>();
@Override
public Object intercept(Invocation invocation) throws Throwable {
sqlIdHolder.set(getSqlId(invocation));
try {
return invocation.proceed();
} finally {
sqlIdHolder.remove(); // 及时清理
}
}
}
Spring Boot 中配置插件
方式一:配置文件方式
如果是纯 MyBatis 配置,使用 mybatis-config.xml:
<plugins>
<plugin interceptor="com.example.plugin.SqlTimePlugin">
<property name="slowSqlThreshold" value="1000"/>
</plugin>
</plugins>
方式二:Spring Bean 方式
在 Spring Boot 中,可以将插件声明为 Bean:
@Configuration
public class MyBatisConfig {
@Bean
public SqlTimePlugin sqlTimePlugin() {
SqlTimePlugin plugin = new SqlTimePlugin();
Properties properties = new Properties();
properties.setProperty("slowSqlThreshold", "1000");
plugin.setProperties(properties);
return plugin;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource,
SqlTimePlugin sqlTimePlugin) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setPlugins(sqlTimePlugin);
return factoryBean.getObject();
}
}
方式三:ConfigurationCustomizer
使用 Spring Boot 的自动配置:
@Configuration
public class MyBatisPluginConfig {
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
// 添加插件
configuration.addInterceptor(new SqlTimePlugin());
};
}
}
常见问题
1. 插件不生效
原因:
- 未正确配置
@Intercepts注解 - 插件未正确注册
解决:
// 确保注解配置正确
@Intercepts({
@Signature(
type = Executor.class, // 确保类型正确
method = "query", // 确保方法名正确
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} // 确保参数类型正确
)
})
2. 无法修改 SQL
原因:BoundSql 的 sql 属性是 final 的
解决:通过反射或创建新的 MappedStatement
// 方式一:反射修改(不推荐)
Field field = BoundSql.class.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
// 方式二:创建新的 BoundSql(推荐)
BoundSql newBoundSql = new BoundSql(
configuration, newSql,
boundSql.getParameterMappings(),
parameterObject
);
3. 插件执行顺序问题
原因:多个插件的执行顺序不确定
解决:
- 按照期望的顺序配置插件
- 在插件内部处理顺序依赖
4. 获取 Mapper 方法参数名
MyBatis 3.4.1+ 支持通过 @Param 注解获取参数名:
// Mapper 方法
List<User> selectByCondition(@Param("username") String username,
@Param("status") Integer status);
// 在插件中获取参数
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object parameter = invocation.getArgs()[1];
if (parameter instanceof Map) {
Map<?, ?> map = (Map<?, ?>) parameter;
String username = (String) map.get("username");
Integer status = (Integer) map.get("status");
}
return invocation.proceed();
}
小结
本章详细介绍了 MyBatis 插件开发:
- 插件原理:四大核心对象和可拦截方法
- Interceptor 接口:intercept、plugin、setProperties 方法
- 注解配置:@Intercepts 和 @Signature 的使用
- 实战示例:SQL 监控、日志打印、分页、数据权限插件
- 最佳实践:拦截点选择、性能优化、线程安全
- Spring Boot 配置:多种插件配置方式
插件是 MyBatis 最强大的扩展机制之一,合理使用可以实现很多高级功能,但也需要注意性能和复杂度的影响。