MyBatis 的 SQL 拦截器:原理、实现与实践

发布于:2025-08-17 ⋅ 阅读:(14) ⋅ 点赞:(0)

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 类中。它的运作方式可以简单概括为:

  1. 扫描拦截器:MyBatis 启动时会扫描所有注册的拦截器(通过 XML 或注解配置)。

  2. 生成代理:为目标组件(比如 Executor)生成代理对象。

  3. 拦截方法调用:当 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 语句。

  • 分页逻辑

    1. 判断是否需要分页(比如检查参数中是否有分页对象)。

    2. 将原始 SQL 改写为带 LIMIT 和 OFFSET 的分页 SQL。

    3. 执行 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 对象。

  • 脱敏逻辑

    1. 检查查询结果的字段是否包含敏感信息(比如 phone、id_card)。

    2. 对敏感字段进行脱敏处理(可以用正则替换或自定义规则)。

    3. 返回修改后的结果集。

  • 配置灵活性:支持通过配置文件定义哪些字段需要脱敏,以及脱敏规则。

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 执行。

  • 切换逻辑

    1. 从上下文中获取目标数据源标识(比如租户 ID)。

    2. 修改 MyBatis 的 SqlSession 或 Connection 到对应的数据源。

    3. 确保事务和连接的正确管理。

  • 集成 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 和参数。

  • 防护逻辑

    1. 检查 SQL 是否包含危险关键字(如 DROP、TRUNCATE)。

    2. 验证参数值是否符合预期格式(比如防止注入恶意字符串)。

    3. 记录可疑 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 解析或参数处理部分。


网站公告

今日签到

点亮在社区的每一天
去签到