MyBatis 的 SQL 拦截器:原理、实现与实践
1. 拦截器是什么?为什么它在 MyBatis 中这么重要?
MyBatis 作为一个轻量级、灵活的 ORM 框架,深受开发者喜爱。它的核心魅力在于高度可定制性,而拦截器(Interceptor)正是这一特性的重要体现。拦截器就像一个“幕后操控者”,可以在 MyBatis 执行 SQL 的关键节点上“插一脚”,让你有机会动态修改 SQL、记录日志、实现权限控制,甚至偷偷摸摸地给查询加个分页。
简单来说,拦截器是 MyBatis 提供的一种插件机制,允许开发者在 SQL 执行的某些环节(比如 SQL 构建、参数绑定、结果映射)中插入自定义逻辑。它本质上是一个动态代理的实现,通过代理模式拦截 MyBatis 核心组件的行为。听起来是不是有点像“黑客帝国”里的特工?它能悄无声息地改变程序的运行轨迹。
1.1 拦截器的核心作用
动态修改 SQL:比如在 SQL 执行前加个 WHERE 条件,或者偷偷把 SELECT * 改成 SELECT COUNT(*)。
性能监控:记录每条 SQL 的执行时间,帮你揪出慢查询。
权限控制:根据用户角色动态调整 SQL,限制某些敏感字段的访问。
日志记录:把 SQL 和参数完整地记下来,方便调试和审计。
1.2 为什么不用 AOP 代替拦截器?
你可能会问:Spring 不是有 AOP 吗?为啥还要用 MyBatis 的拦截器?答案很简单,MyBatis 拦截器更聚焦,它专门为 MyBatis 的核心组件(Executor、ParameterHandler、ResultSetHandler、StatementHandler)设计,粒度更细,操作更精准。AOP 虽然强大,但它是通用方案,缺乏 MyBatis 场景下的语义化支持。用拦截器,你能直接操作 SQL 的“内部零件”,效率更高,代码也更直观。
1.3 一个真实的场景
想象一下,你在开发一个多租户系统,每个租户的数据都要通过 tenant_id 隔离。手动在每个 Mapper 的 SQL 里加 WHERE tenant_id = ? 是不是很烦?有了拦截器,你可以统一在 SQL 执行前动态注入 tenant_id 条件,省时省力,还能避免人为遗漏。
2. MyBatis 拦截器的底层原理:它是怎么“偷窥”SQL 的?
要搞懂拦截器怎么工作,得先明白 MyBatis 的执行流程。MyBatis 的核心组件包括以下几个,它们是拦截器的“目标”:
Executor:负责 SQL 的执行,管理事务和缓存。
ParameterHandler:处理 SQL 参数的绑定。
ResultSetHandler:将数据库返回的结果集映射为 Java 对象。
StatementHandler:准备和执行 SQL 语句。
拦截器通过动态代理机制,包装这些组件,在特定方法调用时插入自定义逻辑。MyBatis 的插件机制基于 JDK 动态代理,核心代码在 Plugin 类中。它的运作方式可以简单概括为:
扫描拦截器:MyBatis 启动时会扫描所有注册的拦截器(通过 XML 或注解配置)。
生成代理:为目标组件(比如 Executor)生成代理对象。
拦截方法调用:当 MyBatis 调用目标组件的方法时,代理对象会先调用拦截器的 intercept 方法,执行你的自定义逻辑。
2.1 拦截器的核心接口
MyBatis 的拦截器需要实现 Interceptor 接口,包含三个方法:
intercept(Invocation invocation):核心拦截逻辑,invocation 包含目标对象、方法和参数。
plugin(Object target):决定是否为目标对象生成代理。
setProperties(Properties properties):接收配置文件中的自定义属性。
2.2 动态代理的魔法
假设你要拦截 Executor 的 query 方法,MyBatis 会为 Executor 创建一个代理对象。当调用 executor.query() 时,实际执行的是代理对象的逻辑,代理会先调用你的 intercept 方法,执行完后再决定是否继续调用原始方法。这种机制让拦截器既灵活又强大。
2.3 一个直观的例子
假设你想记录每条 SQL 的执行时间,拦截器可以在 Executor.query 方法前后加个时间戳,计算耗时。代码大概是这样的:
public class TimeLoggingInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {long start = System.currentTimeMillis();Object result = invocation.proceed(); // 执行原始方法long time = System.currentTimeMillis() - start;System.out.println("SQL 执行耗时: " + time + "ms");return result;}
}
这个例子简单但很实用,接下来我们会深入探讨如何实现更复杂的逻辑。
3. 实现一个简单的 SQL 日志拦截器
让我们从一个简单的例子入手,写一个拦截器来记录 SQL 语句和参数。这样的拦截器在调试时特别有用,能帮你快速定位问题。
3.1 代码实现
我们需要拦截 StatementHandler 的 prepare 方法,因为它负责 SQL 语句的预编译和参数绑定。以下是完整的实现:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlLoggingInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {// 获取 StatementHandlerStatementHandler statementHandler = (StatementHandler) invocation.getTarget();// 获取 BoundSql,包含 SQL 和参数BoundSql boundSql = statementHandler.getBoundSql();String sql = boundSql.getSql();Object parameterObject = boundSql.getParameterObject();// 格式化输出 SQL 和参数System.out.println("执行的 SQL: " + sql);System.out.println("参数: " + parameterObject);// 继续执行原始方法return invocation.proceed();}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可以从配置文件读取参数,比如日志级别}
}
3.2 配置拦截器
在 MyBatis 的配置文件中注册拦截器:
<plugins><plugin interceptor="com.example.SqlLoggingInterceptor"/>
</plugins>
或者用 Spring Boot 的方式:
@Configuration
public class MyBatisConfig {@Beanpublic SqlLoggingInterceptor sqlLoggingInterceptor() {return new SqlLoggingInterceptor();}
}
3.3 运行效果
假设你执行了 SELECT * FROM user WHERE id = ?,拦截器会输出:
执行的 SQL: SELECT * FROM user WHERE id = ?
参数: 123
小贴士:实际生产中,SQL 可能很长,建议用 StringBuilder 格式化 SQL,去掉多余的换行和空格,让日志更清晰。
4. 进阶:动态修改 SQL 实现多租户隔离
现在我们来玩点高级的:用拦截器实现多租户数据隔离。假设每个租户的数据通过 tenant_id 区分,我们希望在所有 SELECT 查询中自动加上 WHERE tenant_id = ?。
4.1 设计思路
拦截 StatementHandler 的 prepare 方法。
解析 SQL,找到 FROM 子句后添加 WHERE 条件。
动态绑定 tenant_id 参数。
4.2 实现代码
以下是一个简化的实现:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler statementHandler = (StatementHandler) invocation.getTarget();BoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();// 简单判断是否为 SELECT 语句if (originalSql.trim().toUpperCase().startsWith("SELECT")) {// 获取当前租户 ID(假设从 ThreadLocal 获取)Long tenantId = TenantContext.getTenantId();if (tenantId != null) {// 简单拼接 WHERE 条件(实际生产中需要更复杂的 SQL 解析)String newSql = originalSql + " WHERE tenant_id = ?";// 使用反射修改 BoundSql 的 SQLField field = BoundSql.class.getDeclaredField("sql");field.setAccessible(true);field.set(boundSql, newSql);// 动态添加参数// 假设参数是 Map 类型,实际需要根据具体参数类型处理if (boundSql.getParameterObject() instanceof Map) {((Map) boundSql.getParameterObject()).put("tenantId", tenantId);}}}return invocation.proceed();}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可以配置 tenant_id 的字段名}
}
4.3 注意事项
SQL 解析:上面的代码简单拼接了 WHERE 条件,实际生产中可能需要用 SQL 解析库(如 JSQLParser)来精确修改 SQL,避免语法错误。
参数绑定:动态添加参数时要小心参数类型的处理,避免类型不匹配导致的错误。
性能开销:频繁修改 SQL 会增加开销,建议缓存解析后的 SQL。
4.4 运行效果
假设原始 SQL 是 SELECT * FROM user,租户 ID 是 1001,拦截器会将其改为:
SELECT * FROM user WHERE tenant_id = ?
参数中会自动绑定 tenantId = 1001。
5. 实战:性能监控拦截器
性能问题是开发中的“隐形杀手”。让我们实现一个拦截器,专门监控 SQL 的执行时间,并对超过阈值的慢查询发出警告。
5.1 实现代码
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import java.util.Properties;@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PerformanceInterceptor implements Interceptor {private long threshold = 1000; // 慢查询阈值,单位毫秒@Overridepublic Object intercept(Invocation invocation) throws Throwable {MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];String sqlId = mappedStatement.getId();long start = System.currentTimeMillis();Object result = invocation.proceed();long time = System.currentTimeMillis() - start;if (time > threshold) {System.err.println("慢查询警告: " + sqlId + " 耗时 " + time + "ms");} else {System.out.println("SQL: " + sqlId + " 耗时 " + time + "ms");}return result;}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {String thresholdValue = properties.getProperty("threshold");if (thresholdValue != null) {this.threshold = Long.parseLong(thresholdValue);}}
}
5.2 配置阈值
在 MyBatis 配置文件中设置慢查询阈值:
<plugins><plugin interceptor="com.example.PerformanceInterceptor"><property name="threshold" value="500"/></plugin>
</plugins>
5.3 运行效果
执行一条 SQL,若耗时超过 500ms,控制台会输出:
慢查询警告: com.example.UserMapper.selectById 耗时 600ms
小贴士:可以把慢查询记录到日志文件或监控系统中,比如集成 ELK 或 Prometheus,方便后续分析。
6. 更进一步:实现分页拦截器
分页查询是企业级应用中的常见需求,但手写分页逻辑不仅繁琐,还容易出错。MyBatis 提供了 PageHelper 这样的分页插件,但如果你想完全掌控分页逻辑,或者公司有特殊的分页需求,自定义一个分页拦截器会是个不错的选择。接下来,我们就来实现一个通用的分页拦截器,让分页像呼吸一样简单。
6.1 设计思路
拦截对象:依然是 StatementHandler,因为它直接操作 SQL 语句。
分页逻辑:
判断是否需要分页(比如检查参数中是否有分页对象)。
将原始 SQL 改写为带 LIMIT 和 OFFSET 的分页 SQL。
执行 COUNT 查询,获取总记录数。
参数绑定:动态添加分页参数(如 offset 和 limit)。
6.2 代码实现
假设我们有一个 PageParam 类,包含 pageNum 和 pageSize 属性,用于传递分页参数。以下是分页拦截器的实现:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class PaginationInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler statementHandler = (StatementHandler) invocation.getTarget();BoundSql boundSql = statementHandler.getBoundSql();Object parameterObject = boundSql.getParameterObject();String originalSql = boundSql.getSql();// 检查是否需要分页PageParam pageParam = extractPageParam(parameterObject);if (pageParam == null || !originalSql.trim().toUpperCase().startsWith("SELECT")) {return invocation.proceed();}// 改写 SQL 为分页查询String pageSql = originalSql + " LIMIT ? OFFSET ?";Field field = BoundSql.class.getDeclaredField("sql");field.setAccessible(true);field.set(boundSql, pageSql);// 添加分页参数if (parameterObject instanceof Map) {Map<String, Object> paramMap = (Map<String, Object>) parameterObject;paramMap.put("limit", pageParam.getPageSize());paramMap.put("offset", (pageParam.getPageNum() - 1) * pageParam.getPageSize());}// 执行 COUNT 查询long total = executeCountQuery(statementHandler, originalSql);pageParam.setTotal(total);return invocation.proceed();}private PageParam extractPageParam(Object parameterObject) {if (parameterObject instanceof PageParam) {return (PageParam) parameterObject;} else if (parameterObject instanceof Map) {for (Object value : ((Map<?, ?>) parameterObject).values()) {if (value instanceof PageParam) {return (PageParam) value;}}}return null;}private long executeCountQuery(StatementHandler statementHandler, String originalSql) throws SQLException {String countSql = "SELECT COUNT(*) FROM (" + originalSql + ") tmp_count";Connection connection = (Connection) statementHandler.getBoundSql().getParameterObject();PreparedStatement countStmt = connection.prepareStatement(countSql);statementHandler.getParameterHandler().setParameters(countStmt);ResultSet rs = countStmt.executeQuery();long total = 0;if (rs.next()) {total = rs.getLong(1);}rs.close();countStmt.close();return total;}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可配置分页参数名等}
}class PageParam {private int pageNum;private int pageSize;private long total;// Getters and Setters
}
6.3 使用方式
在 Mapper 方法中传入 PageParam 对象:
List<User> selectUsers(@Param("page") PageParam page);
执行后,拦截器会自动将 SQL 改写为带 LIMIT 的形式,并设置 pageParam.total 为总记录数。
6.4 注意事项
SQL 兼容性:不同数据库的分页语法不同(MySQL 用 LIMIT,PostgreSQL 用 LIMIT/OFFSET,Oracle 用 ROWNUM)。需要根据数据库类型动态调整 SQL。
性能优化:COUNT 查询可能很慢,尤其是表数据量大时,建议缓存总记录数。
参数处理:实际场景中,参数可能很复杂,建议用 MyBatis 的 ParameterHandler 来规范化参数绑定。
效果展示: 原始 SQL:SELECT * FROM user WHERE age > 20改写后:SELECT * FROM user WHERE age > 20 LIMIT 10 OFFSET 0总记录数会自动写入 PageParam 的 total 属性。
7. 动态表名替换:应对分表分库的挑战
在高并发场景下,分表分库是常见的优化手段。但 MyBatis 的 Mapper 文件通常是静态的,表名写死了怎么办?拦截器可以帮你动态替换表名,让分表像换衣服一样轻松。
7.1 场景分析
假设你有一个 user 表,根据用户 ID 哈希分成了 user_0、user_1 等多个表。我们希望拦截器能根据参数动态替换表名。
7.2 实现代码
以下是一个动态替换表名的拦截器:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TableShardInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler statementHandler = (StatementHandler) invocation.getTarget();BoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();Object parameterObject = boundSql.getParameterObject();// 获取分表参数(假设从参数中获取 userId)Long userId = extractUserId(parameterObject);if (userId != null) {// 根据 userId 计算表名String tableSuffix = String.valueOf(userId % 4); // 假设分 4 张表String newTableName = "user_" + tableSuffix;String newSql = originalSql.replaceAll("user\\b", newTableName);// 修改 SQLField field = BoundSql.class.getDeclaredField("sql");field.setAccessible(true);field.set(boundSql, newSql);}return invocation.proceed();}private Long extractUserId(Object parameterObject) {if (parameterObject instanceof Map) {return (Long) ((Map<?, ?>) parameterObject).get("userId");} else if (parameterObject instanceof Long) {return (Long) parameterObject;}return null;}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可配置分表规则}
}
7.3 运行效果
原始 SQL:SELECT * FROM user WHERE id = ?替换后:SELECT * FROM user_2 WHERE id = ?(假设 userId = 10,10 % 4 = 2)
7.4 进阶优化
正则替换:简单用 replaceAll 可能误替换非表名的 user 字符串,建议用 JSQLParser 解析 SQL,确保只替换 FROM 后的表名。
分表策略:可以从配置文件或数据库读取分表规则,增加灵活性。
多数据源:如果涉及分库,还需要配合 MyBatis 的多数据源配置。
8. 避免踩坑:拦截器的常见问题与解决方案
拦截器虽然强大,但用不好也容易翻车。以下是一些常见的坑和应对策略,一定要看,血泪教训!
8.1 性能问题
问题:拦截器逻辑复杂(如频繁解析 SQL)会导致性能下降。
解决方案:缓存解析后的 SQL 或者使用高效的 SQL 解析库(如 JSQLParser)。避免在拦截器中执行耗时操作,比如网络请求。
8.2 SQL 语法错误
问题:动态修改 SQL 可能导致语法错误,比如在子查询中错误添加 WHERE 条件。
解决方案:使用成熟的 SQL 解析工具,确保改写后的 SQL 语法正确。测试时覆盖各种 SQL 场景(子查询、JOIN、UNION 等)。
8.3 参数绑定异常
问题:动态添加参数时,类型不匹配或参数顺序错误会导致执行失败。
解决方案:通过 ParameterHandler 规范化参数绑定,确保参数类型和顺序正确。
8.4 拦截器顺序问题
问题:多个拦截器可能互相干扰,比如一个拦截器改了 SQL,另一个拦截器又改了一次,导致冲突。
解决方案:在配置拦截器时明确顺序,必要时在拦截器中检查上下文,避免重复处理。
8.5 调试困难
问题:拦截器逻辑复杂时,调试起来很头疼,尤其是在生产环境中。
解决方案:在拦截器中添加详细日志,记录原始 SQL、改写后的 SQL 和参数。可以用 SLF4J 集成日志框架,方便切换日志级别。
9. 最佳实践:让你的拦截器更健壮
写一个健壮的拦截器不仅需要技术,还需要点“艺术感”。以下是一些实战经验,帮你把拦截器写得又稳又优雅。
9.1 模块化设计
将拦截器的逻辑拆分成小模块,比如 SQL 解析、参数处理、日志记录等,方便维护和测试。
9.2 异常处理
拦截器中一定要做好异常处理,避免因为一个小错误导致整个 SQL 执行失败。例如:
@Override
public Object intercept(Invocation invocation) throws Throwable {try {// 核心逻辑return invocation.proceed();} catch (Exception e) {log.error("拦截器执行失败", e);throw e; // 或者根据需求返回默认结果}
}
9.3 配置化
通过 setProperties 方法支持配置化,比如配置慢查询阈值、分表规则等。这样可以让拦截器更灵活,适应不同场景。
9.4 测试覆盖
写单元测试,模拟各种 SQL 和参数场景,确保拦截器在极端情况下也能正常工作。可以用 H2 数据库做内存测试,快速验证 SQL 改写的正确性。
9.5 文档化
拦截器可能被团队其他成员使用,写好注释和文档,说明拦截器的功能、配置方式和注意事项。
10. 数据脱敏:用拦截器保护敏感信息
在如今数据隐私备受关注的年代,保护用户敏感信息是开发者的必修课。比如,用户的身份证号、手机号在查询结果中不能直接暴露,需要脱敏处理(比如把 13812345678 变成 138****5678)。MyBatis 拦截器可以轻松搞定这个需求,让数据安全和开发效率两不误。
10.1 设计思路
拦截对象:ResultSetHandler,因为它负责将数据库结果集映射为 Java 对象。
脱敏逻辑:
检查查询结果的字段是否包含敏感信息(比如 phone、id_card)。
对敏感字段进行脱敏处理(可以用正则替换或自定义规则)。
返回修改后的结果集。
配置灵活性:支持通过配置文件定义哪些字段需要脱敏,以及脱敏规则。
10.2 代码实现
以下是一个简单的脱敏拦截器实现,假设我们需要对 phone 字段进行脱敏:
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.*;
import java.sql.Statement;
import java.util.List;
import java.util.Properties;
import java.util.regex.Pattern;@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
public class DataMaskingInterceptor implements Interceptor {private static final Pattern PHONE_PATTERN = Pattern.compile("(\\d{3})\\d{4}(\\d{4})");@Overridepublic Object intercept(Invocation invocation) throws Throwable {Object result = invocation.proceed();// 处理结果集if (result instanceof List) {List<?> resultList = (List<?>) result;for (Object item : resultList) {maskSensitiveFields(item);}} else {maskSensitiveFields(result);}return result;}private void maskSensitiveFields(Object item) throws Exception {if (item == null) return;// 假设结果是 POJO,使用反射处理for (Field field : item.getClass().getDeclaredFields()) {field.setAccessible(true);if (field.getName().equalsIgnoreCase("phone") && field.get(item) instanceof String) {String phone = (String) field.get(item);if (phone != null && !phone.isEmpty()) {String maskedPhone = PHONE_PATTERN.matcher(phone).replaceAll("$1****$2");field.set(item, maskedPhone);}}}}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可以配置脱敏字段和规则}
}
10.3 使用场景
假设你的 Mapper 返回了一个 User 对象,包含 phone 字段:
public class User {private String name;private String phone;// Getters and Setters
}
原始查询结果:{name: "张三", phone: "13812345678"}拦截器处理后:{name: "张三", phone: "138****5678"}
10.4 优化建议
性能优化:反射操作性能较低,建议用注解标记需要脱敏的字段,减少反射开销。例如:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {String type() default "phone"; // 支持多种脱敏类型
}
灵活配置:通过 setProperties 支持动态配置脱敏规则,比如从配置文件读取需要脱敏的字段名。
复杂对象:如果结果是嵌套对象(如 List),需要递归处理嵌套结构。
安全性:确保脱敏后的数据不会被其他拦截器或代码意外覆盖。
小贴士:脱敏规则可以更复杂,比如身份证号只显示前 6 位和后 4 位,邮箱只显示前缀前 3 个字符等。根据业务需求定制规则,灵活应对。
11. 复杂 SQL 重写:应对动态业务场景
有时候,业务需求会复杂到让你怀疑人生。比如,某个查询需要根据用户角色动态调整返回的字段,或者需要根据参数动态添加 JOIN 语句。这些场景用普通的 Mapper 写起来费劲,拦截器却能大显身手,像个魔法师一样改写 SQL。
11.1 场景分析
假设你有一个查询,根据用户角色决定是否返回敏感字段(如 salary)。普通用户只能看到基本信息,管理员可以看到所有字段。我们可以用拦截器动态调整 SQL 的 SELECT 部分。
11.2 实现代码
以下是一个基于用户角色的 SQL 重写拦截器:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
import net.sf.jsqlparser.util.deparser.SelectDeParser;
import java.sql.Connection;
import java.util.Properties;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class RoleBasedSqlInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler statementHandler = (StatementHandler) invocation.getTarget();BoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();// 只处理 SELECT 语句if (!originalSql.trim().toUpperCase().startsWith("SELECT")) {return invocation.proceed();}// 获取用户角色(假设从上下文获取)String role = UserContext.getCurrentRole();if ("admin".equalsIgnoreCase(role)) {return invocation.proceed(); // 管理员直接返回原始 SQL}// 使用 JSQLParser 解析 SQLSelect select = (Select) CCJSqlParserUtil.parse(originalSql);StringBuilder modifiedSql = new StringBuilder();SelectDeParser deParser = new SelectDeParser() {@Overridepublic void visit(SelectBody selectBody) {// 自定义字段过滤逻辑,排除敏感字段selectBody.getSelectItems().removeIf(item -> {String column = item.toString().toLowerCase();return column.contains("salary") || column.contains("credit_card");});super.visit(selectBody);}};select.getSelectBody().accept(deParser);modifiedSql.append(select.toString());// 修改 BoundSqlField field = BoundSql.class.getDeclaredField("sql");field.setAccessible(true);field.set(boundSql, modifiedSql.toString());return invocation.proceed();}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可配置敏感字段列表}
}
11.3 依赖 JSQLParser
为了精确解析和改写 SQL,我们引入了 JSQLParser 库。Maven 依赖如下:
<dependency><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId><version>4.6</version>
</dependency>
11.4 运行效果
原始 SQL:SELECT name, salary, credit_card FROM employee普通用户执行后:SELECT name FROM employee管理员执行后:保持原始 SQL 不变。
11.5 注意事项
SQL 解析性能:JSQLParser 虽然强大,但解析复杂 SQL 可能有性能开销,建议缓存解析结果。
字段别名:如果 SQL 中有别名(AS),需要额外处理别名逻辑。
复杂场景:如果涉及 JOIN 或子查询,需要更复杂的解析逻辑,确保改写后 SQL 语义正确。
彩蛋:JSQLParser 还能用来分析 SQL 的结构,比如提取 WHERE 条件、JOIN 关系等,功能远不止改写 SELECT 字段,值得深入研究!
12. 与 Spring 集成:让拦截器开发更丝滑
在 Spring 生态中,MyBatis 通常通过 mybatis-spring 集成使用。拦截器结合 Spring 的一些特性(比如依赖注入、AOP),可以让开发体验更顺畅,简直像开了外挂。
12.1 使用 Spring 管理拦截器
通过 Spring 的 @Bean 注解注册拦截器,方便注入其他服务(如日志服务、配置服务):
@Configuration
public class MyBatisConfig {@Beanpublic SqlLoggingInterceptor sqlLoggingInterceptor(LogService logService) {SqlLoggingInterceptor interceptor = new SqlLoggingInterceptor();interceptor.setLogService(logService); // 注入日志服务return interceptor;}@Beanpublic ConfigurationCustomizer mybatisConfigurationCustomizer(SqlLoggingInterceptor interceptor) {return configuration -> configuration.addInterceptor(interceptor);}
}
12.2 使用 Spring 的 ThreadLocal
多租户或角色控制场景中,ThreadLocal 是管理上下文的利器。可以用 Spring 的 @Component 封装上下文:
@Component
public class TenantContext {private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();public static void setTenantId(Long tenantId) {TENANT_ID.set(tenantId);}public static Long getTenantId() {return TENANT_ID.get();}public static void clear() {TENANT_ID.remove();}
}
拦截器中使用:
Long tenantId = TenantContext.getTenantId();
12.3 集成 Spring AOP
如果某些逻辑需要同时作用于 MyBatis 和其他服务,可以用 Spring AOP 辅助拦截器。例如,记录所有数据库操作的审计日志:
@Aspect
@Component
public class AuditAspect {@Around("execution(* com.example.service.*.*(..))")public Object logAudit(ProceedingJoinPoint joinPoint) throws Throwable {String methodName = joinPoint.getSignature().toString();log.info("开始执行: " + methodName);Object result = joinPoint.proceed();log.info("执行结束: " + methodName);return result;}
}
12.4 好处总结
依赖注入:通过 Spring 注入服务,减少拦截器中的硬编码。
配置管理:用 Spring 的 @Value 或 ConfigurationProperties 管理拦截器配置。
统一上下文:Spring 的 RequestContextHolder 或 ThreadLocal 可以共享请求上下文,简化多租户逻辑。
小贴士:Spring Boot 用户可以用 @MapperScan 自动扫描 Mapper,同时通过 SqlSessionFactoryBean 定制 MyBatis 配置,省去 XML 配置的麻烦。
13. 动态数据源切换:让拦截器玩转多数据源
在分布式系统或多租户场景中,动态数据源切换是常见需求。比如,不同租户的数据可能存储在不同的数据库实例中,我们希望拦截器能根据上下文动态选择目标数据源。这就像给 MyBatis 装了个 GPS,随时切换路线!
13.1 设计思路
拦截对象:Executor,因为它负责数据库连接的获取和 SQL 执行。
切换逻辑:
从上下文中获取目标数据源标识(比如租户 ID)。
修改 MyBatis 的 SqlSession 或 Connection 到对应的数据源。
确保事务和连接的正确管理。
集成 Spring:借助 Spring 的 AbstractRoutingDataSource 实现动态数据源切换。
13.2 实现代码
以下是一个结合 Spring 的动态数据源切换拦截器:
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import java.util.Properties;@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class DynamicDataSourceInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {// 获取当前租户 ID(假设从 ThreadLocal 获取)Long tenantId = TenantContext.getTenantId();if (tenantId != null) {// 设置数据源标识DataSourceContextHolder.setDataSourceKey("tenant_" + tenantId);} else {DataSourceContextHolder.setDataSourceKey("default");}try {return invocation.proceed();} finally {// 清理上下文,防止线程复用导致数据源错乱DataSourceContextHolder.clearDataSourceKey();}}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可配置默认数据源}
}// 上下文管理器
public class DataSourceContextHolder {private static final ThreadLocal<String> DATA_SOURCE_KEY = new ThreadLocal<>();public static void setDataSourceKey(String key) {DATA_SOURCE_KEY.set(key);}public static String getDataSourceKey() {return DATA_SOURCE_KEY.get();}public static void clearDataSourceKey() {DATA_SOURCE_KEY.remove();}
}// Spring 配置
@Configuration
public class DataSourceConfig {@Beanpublic DataSource dataSource() {AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {@Overrideprotected Object determineCurrentLookupKey() {return DataSourceContextHolder.getDataSourceKey();}};Map<Object, Object> targetDataSources = new HashMap<>();targetDataSources.put("default", defaultDataSource());targetDataSources.put("tenant_1", tenant1DataSource());targetDataSources.put("tenant_2", tenant2DataSource());routingDataSource.setTargetDataSources(targetDataSources);routingDataSource.setDefaultTargetDataSource(defaultDataSource());return routingDataSource;}@Beanpublic DynamicDataSourceInterceptor dynamicDataSourceInterceptor() {return new DynamicDataSourceInterceptor();}
}
13.3 配置数据源
在 application.yml 中配置多个数据源:
spring:datasource:default:url: jdbc:mysql://localhost:3306/default_dbusername: rootpassword: 123456tenant_1:url: jdbc:mysql://localhost:3306/tenant1_dbusername: rootpassword: 123456tenant_2:url: jdbc:mysql://localhost:3306/tenant2_dbusername: rootpassword: 123456
13.4 运行效果
当 TenantContext.setTenantId(1) 时,拦截器会将数据源切换到 tenant_1 对应的数据库,所有 SQL 都在该数据库执行。执行完成后,清理上下文,确保线程安全。
13.5 注意事项
线程安全:ThreadLocal 必须在请求结束时清理,否则线程池复用可能导致数据源错乱。
事务管理:动态切换数据源可能影响事务一致性,建议结合 Spring 的 @Transactional 确保事务正确性。
性能开销:频繁切换数据源可能增加连接池开销,建议优化连接池配置。
小贴士:如果数据源数量较多,可以用数据库或配置中心动态管理数据源信息,减少硬编码。
14. SQL 注入防护:拦截器的安全卫士
SQL 注入是老生常谈的安全问题,虽然 MyBatis 的参数化查询已经很大程度上避免了注入风险,但某些动态 SQL 场景(比如拼接表名或动态条件)仍然可能存在漏洞。拦截器可以作为最后一道防线,像个安保人员一样检查 SQL 的合法性。
14.1 设计思路
拦截对象:StatementHandler,检查 SQL 和参数。
防护逻辑:
检查 SQL 是否包含危险关键字(如 DROP、TRUNCATE)。
验证参数值是否符合预期格式(比如防止注入恶意字符串)。
记录可疑 SQL,方便审计。
日志集成:将可疑操作记录到日志或告警系统。
14.2 代码实现
以下是一个简单的 SQL 注入防护拦截器:
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.util.Properties;
import java.util.regex.Pattern;@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlInjectionInterceptor implements Interceptor {private static final Logger log = LoggerFactory.getLogger(SqlInjectionInterceptor.class);private static final Pattern DANGEROUS_PATTERN = Pattern.compile("(?i)\\b(DROP|TRUNCATE|DELETE\\s+FROM)\\b");@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler statementHandler = (StatementHandler) invocation.getTarget();BoundSql boundSql = statementHandler.getBoundSql();String sql = boundSql.getSql();// 检查 SQL 是否包含危险关键字if (DANGEROUS_PATTERN.matcher(sql).find()) {log.error("检测到潜在 SQL 注入: {}", sql);throw new SecurityException("危险 SQL 操作被拦截: " + sql);}// 检查参数(简单示例,检查字符串参数)Object parameterObject = boundSql.getParameterObject();if (parameterObject instanceof String) {String param = (String) parameterObject;if (param.contains(";") || param.contains("--")) {log.error("检测到可疑参数: {}", param);throw new SecurityException("可疑参数被拦截: " + param);}}return invocation.proceed();}@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 可配置危险关键字列表}
}
14.3 运行效果
如果 SQL 包含 DROP TABLE user,拦截器会抛出异常并记录日志:
ERROR: 检测到潜在 SQL 注入: DROP TABLE user
14.4 优化建议
正则优化:完善危险关键字正则,避免误判合法 SQL。
白名单机制:允许特定 SQL 模式通过,减少误拦截。
告警集成:将可疑 SQL 发送到告警系统(如邮件、Slack),方便及时响应。
动态 SQL 场景:如果业务中大量使用动态 SQL,建议结合 JSQLParser 做更精准的语法分析。
彩蛋:SQL 注入防护还可以结合 WAF(Web 应用防火墙)或 ORM 的参数化查询,形成多层次防护体系。
15. 调试与性能优化:让拦截器跑得又快又稳
拦截器虽好,但写不好可能变成性能瓶颈或调试噩梦。以下是一些实战经验,帮你把拦截器调得又快又稳。
15.1 调试技巧
日志分级:用 SLF4J 的日志级别(DEBUG、INFO、ERROR)记录不同场景的信息。比如,DEBUG 记录原始和改写后的 SQL,ERROR 记录异常。
断点调试:在 intercept 方法中设置断点,检查 Invocation 的参数和目标对象。
SQL 验证:用 H2 或 SQLite 搭建内存数据库,快速验证改写后的 SQL 语法正确性。
15.2 性能优化
缓存结果:对于频繁执行的 SQL,缓存解析或改写结果。比如,分表拦截器可以缓存表名映射。
减少反射:反射操作(如修改 BoundSql 的 sql 字段)性能较低,尽量用 MyBatis 提供的 API。
异步日志:日志记录可能阻塞主线程,建议用异步日志框架(如 Logback 的 AsyncAppender)。
拦截器精简:一个拦截器只做一件事,避免把所有逻辑堆在一个拦截器里。
15.3 示例:异步日志优化
将日志写入改为异步:
<!-- Logback 配置 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"><appender-ref ref="FILE" />
</appender><appender name="FILE" class="ch.qos.logback.core.FileAppender"><file>logs/mybatis.log</file><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %msg%n</pattern></encoder>
</appender>
拦截器中使用:
log.debug("执行 SQL: {}", sql);
15.4 性能监控
可以用前面提到的 PerformanceInterceptor 监控拦截器本身的性能,确保它不会成为瓶颈。如果发现某个拦截器耗时过长,分析其逻辑,优化 SQL 解析或参数处理部分。