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 {
@Override
public 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 {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取 StatementHandler
StatementHandler 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();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以从配置文件读取参数,比如日志级别
}
}
3.2 配置拦截器
在 MyBatis 的配置文件中注册拦截器:
<plugins>
<plugin interceptor="com.example.SqlLoggingInterceptor"/>
</plugins>
或者用 Spring Boot 的方式:
@Configuration
public class MyBatisConfig {
@Bean
public 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 {
@Override
public 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 的 SQL
Field 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();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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; // 慢查询阈值,单位毫秒
@Override
public 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;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 {
@Override
public 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;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 {
@Override
public 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);
// 修改 SQL
Field 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;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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})");
@Override
public 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);
}
}
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 {
@Override
public 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 解析 SQL
Select select = (Select) CCJSqlParserUtil.parse(originalSql);
StringBuilder modifiedSql = new StringBuilder();
SelectDeParser deParser = new SelectDeParser() {
@Override
public 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());
// 修改 BoundSql
Field field = BoundSql.class.getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, modifiedSql.toString());
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 {
@Bean
public SqlLoggingInterceptor sqlLoggingInterceptor(LogService logService) {
SqlLoggingInterceptor interceptor = new SqlLoggingInterceptor();
interceptor.setLogService(logService); // 注入日志服务
return interceptor;
}
@Bean
public 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 {
@Override
public 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();
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 {
@Bean
public DataSource dataSource() {
AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
@Override
protected 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;
}
@Bean
public DynamicDataSourceInterceptor dynamicDataSourceInterceptor() {
return new DynamicDataSourceInterceptor();
}
}
13.3 配置数据源
在 application.yml 中配置多个数据源:
spring:
datasource:
default:
url: jdbc:mysql://localhost:3306/default_db
username: root
password: 123456
tenant_1:
url: jdbc:mysql://localhost:3306/tenant1_db
username: root
password: 123456
tenant_2:
url: jdbc:mysql://localhost:3306/tenant2_db
username: root
password: 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");
@Override
public 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();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public 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 解析或参数处理部分。