跳到主要内容

插件开发

插件是 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 对象列表。它处理结果映射、嵌套映射等逻辑。

可拦截的方法

核心对象可拦截方法说明
Executorupdate执行 insert/update/delete
query执行查询
flushStatements刷新批处理语句
commit提交事务
rollback回滚事务
getTransaction获取事务对象
close关闭执行器
isClosed判断是否已关闭
StatementHandlerprepare预编译 SQL
parameterize设置参数
batch批量执行
update执行更新
query执行查询
ParameterHandlergetParameterObject获取参数对象
setParameters设置参数
ResultSetHandlerhandleResultSets处理结果集
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 日志记录Executorquery, update
执行时间统计Executorquery, update
分页处理StatementHandlerprepare
参数处理ParameterHandlersetParameters
结果处理ResultSetHandlerhandleResultSets
数据权限Executorquery, 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 最强大的扩展机制之一,合理使用可以实现很多高级功能,但也需要注意性能和复杂度的影响。