无论是日常开发中的数据权限控制、SQL审计监控 ,还是框架层面的分页处理、多租户隔离、乐观锁实现,其底层都依赖MP的SQL拦截器机制。很多开发者只停留在"用内置拦截器"的层面,却不清楚其底层原理,遇到自定义扩展场景时无从下手;甚至滥用拦截器导致SQL性能退化、业务逻辑异常。
本篇文章将从「原理拆解→内置拦截器实战→自定义拦截器开发→企业级避坑」四个维度,结合真实业务场景,带你彻底吃透MP SQL拦截器!
🌟**【青柠代码录】--- Java全栈成长加速器** 🌟
🔥博客合集: https://www.yuque.com/u12587869/zplytb/ur5ohwqxd2axtiny 🔥
一、先搞懂:MyBatis-Plus SQL拦截器的底层逻辑
1.1 核心定位:不是新增功能,而是"切面增强"
MP的SQL拦截器,本质是对MyBatis原生拦截器机制的封装和增强。MyBatis允许通过动态代理,在SQL执行的关键节点插入自定义逻辑(类似AOP的环绕通知),而MP基于此,提供了更简洁的接口、更完善的拦截链管理,让开发者无需关注原生MyBatis的复杂细节,就能快速实现拦截逻辑。
核心:不侵入业务代码,所有拦截逻辑都独立于Service、Mapper层,通过配置生效,降低代码耦合度,这也是它在企业开发中被广泛使用的核心原因。
1.2 底层依赖:MyBatis的四大拦截点
MyBatis原生只允许拦截4个核心接口的方法,MP的所有拦截器(内置/自定义)都围绕这4个拦截点展开,这是理解拦截器的基础,务必牢记👇:
| 拦截接口 | 核心作用 | MP常用拦截场景 |
|---|---|---|
| Executor | SQL执行的核心入口(负责调用StatementHandler执行SQL),拦截增删改查所有操作 | SQL审计、慢SQL监控、数据变更日志 |
| StatementHandler | 负责SQL语句的准备、参数绑定,是修改SQL的核心拦截点 | 分页处理、多租户隔离、动态表名、SQL注入防护 |
| ParameterHandler | 负责SQL参数的处理和绑定(将Java参数转换为JDBC参数) | 参数脱敏、参数格式转换 |
| ResultSetHandler | 负责SQL查询结果的封装(将JDBC结果集转换为Java实体) | 查询结果脱敏、字段映射自定义、数据权限过滤(后置) |
四大拦截点的执行顺序固定,对应SQL执行的生命周期,不可颠倒,顺序为:Executor → ParameterHandler → StatementHandler → ResultSetHandler。
示例:比如用户登录时,密码参数需要加密后传递给SQL,此时需拦截ParameterHandler,将前端传递的明文密码转换为加密后的密文;而查询用户列表时,手机号需要脱敏后返回,此时需拦截ResultSetHandler,在结果封装后修改手机号字段值------两者拦截点不同,职责也不同,不可混淆。
1.3 MP拦截器架构:MybatisPlusInterceptor + InnerInterceptor
在MP 3.4.0版本之后,官方推荐使用「MybatisPlusInterceptor + InnerInterceptor」的组合模式实现拦截逻辑,替代了早期直接实现MyBatis Interceptor接口的方式,这种设计更符合"责任链模式",便于多拦截器协同工作。
1.3.1 核心组件说明
-
MybatisPlusInterceptor:MP的核心拦截器"容器",实现了MyBatis的Interceptor接口,负责管理所有InnerInterceptor,统一拦截入口,控制拦截链的执行顺序(先进后出)。
-
InnerInterceptor:MP提供的拦截器接口,封装了拦截逻辑的规范,开发者自定义拦截器时,只需实现该接口(或其子类),无需关注原生MyBatis的插件代理细节。
1.3.2 核心依赖引入
企业开发中,通常结合Spring Boot使用MP,需引入以下依赖(版本推荐3.5.3.1,稳定且兼容主流Spring Boot版本),避免版本冲突:
<!-- Spring Boot Starter(核心依赖) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus Starter(自动集成MyBatis、数据源) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- 数据库驱动(MySQL 8.x,根据实际数据库调整) -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- lombok(简化实体类,企业开发必备) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- SQL解析依赖(自定义拦截器修改SQL时必备) -->
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.6</version>
</dependency>
二、实战必备:MP内置SQL拦截器
MP提供了多个开箱即用的内置InnerInterceptor,覆盖企业开发中80%的常见场景,无需自定义,只需简单配置即可生效,重点讲解5个高频拦截器。
2.1 分页拦截器:PaginationInnerInterceptor(最常用)
2.1.1 应用场景
替代传统的PageHelper分页插件,实现物理分页(在SQL中拼接LIMIT/OFFSET,而非内存分页),支持MySQL、Oracle、PostgreSQL等多种数据库,适配MP的BaseMapper分页方法(selectPage)。
企业开发中,所有列表分页查询(如用户列表、订单列表)都依赖此拦截器,避免内存分页导致的性能问题。
2.1.2 完整配置
package com.example.mp.interceptor.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 拦截器核心配置类(企业开发标准配置)
* 所有内置/自定义拦截器,都需注册到MybatisPlusInterceptor中
*/
@Configuration
public class MyBatisPlusInterceptorConfig {
/**
* 分页拦截器配置(核心)
* @return MybatisPlusInterceptor 拦截器容器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 分页拦截器(指定数据库类型,避免自动检测耗时)
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
// 设置数据库类型(MySQL,根据实际项目调整:ORACLE、POSTGRE_SQL等)
paginationInterceptor.setDbType(com.baomidou.mybatisplus.annotation.DbType.MYSQL);
// 开启分页合理化:页码<=0 查第一页,页码>总页数 查最后一页(避免用户输入非法页码报错)
paginationInterceptor.setOverflow(true);
// 设置默认每页条数(可选,默认10条)
paginationInterceptor.setDefaultLimit(10);
// 将分页拦截器添加到容器中(顺序很重要,后面会讲)
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
2.1.3 使用示例
// 1. 实体类(User.java)
@Data
@TableName("t_user") // 对应数据库表名
public class User {
@TableId(type = IdType.AUTO) // 自增主键
private Long id;
private String username;
private String password; // 实际开发中会加密存储
private Integer age;
private String deptId; // 部门ID(用于后续数据权限)
@TableField(fill = FieldFill.INSERT) // 插入时自动填充
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入/更新时自动填充
private LocalDateTime updateTime;
}
// 2. Mapper接口(UserMapper.java)
public interface UserMapper extends BaseMapper<User> {
// 无需手动编写分页SQL,BaseMapper已提供selectPage方法
}
// 3. Service层(UserService.java)
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
/**
* 企业级分页查询示例:查询用户列表(带条件)
* @param pageNum 页码
* @param pageSize 每页条数
* @param username 用户名模糊查询(可选条件)
* @return 分页结果(包含总条数、总页数、当前页数据)
*/
public IPage<User> getUserPage(Integer pageNum, Integer pageSize, String username) {
// 1. 构建分页条件(Page是MP提供的分页对象)
IPage<User> page = new Page<>(pageNum, pageSize);
// 2. 构建查询条件(LambdaQueryWrapper,避免字段名硬编码,防SQL注入)
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
// 模糊查询(若username不为空,则添加条件)
if (StrUtil.isNotBlank(username)) {
queryWrapper.like(User::getUsername, username);
}
// 按创建时间降序排序(企业列表常用排序方式)
queryWrapper.orderByDesc(User::getCreateTime);
// 3. 调用BaseMapper的selectPage方法,分页拦截器自动生效
// 底层逻辑:拦截StatementHandler.prepare方法,拼接LIMIT (pageNum-1)*pageSize, pageSize
return userMapper.selectPage(page, queryWrapper);
}
}
// 4. 测试类(实战验证)
@SpringBootTest
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testGetUserPage() {
// 测试:查询第1页,每页5条,用户名包含"张"
IPage<User> userPage = userService.getUserPage(1, 5, "张");
// 输出分页信息(企业开发中会返回给前端)
System.out.println("总条数:" + userPage.getTotal());
System.out.println("总页数:" + userPage.getPages());
System.out.println("当前页数据:" + userPage.getRecords());
}
}
2.1.4 底层原理(简化理解)
分页拦截器拦截「StatementHandler.prepare」方法(SQL准备阶段),通过JSqlParser解析原始SQL,根据数据库类型拼接分页语句:
-
MySQL:原始SQL + 「LIMIT ? OFFSET ?」
-
Oracle:原始SQL + 「ROWNUM <= ? AND ROWNUM > ?」 同时,自动计算分页参数(offset = (pageNum-1)*pageSize),绑定到SQL中,实现物理分页。
2.2 多租户拦截器:TenantLineInnerInterceptor
2.2.1 应用场景
多租户架构(如SaaS系统)中,不同租户的数据存储在同一张表中,通过「租户ID」字段隔离数据,避免租户之间数据泄露。拦截所有查询/更新/删除SQL,自动拼接「tenant_id = ?」条件,无需手动添加。
示例:原始SQL「SELECT * FROM t_user」→ 拦截后「SELECT * FROM t_user WHERE tenant_id = 1」(1为当前登录租户ID)。
2.2.2 完整配置(结合分页拦截器)
@Configuration
public class MyBatisPlusInterceptorConfig {
/**
* 多租户字段名(数据库表中存储租户ID的字段,企业开发中统一命名)
*/
private static final String TENANT_ID_FIELD = "tenant_id";
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 多租户拦截器(必须放在分页拦截器之前!顺序影响SQL解析)
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(
// 租户处理器:获取当前登录租户ID,拼接租户条件
new TenantLineHandler() {
/**
* 返回当前租户ID(企业开发中,从ThreadLocal获取登录用户的租户ID)
* 实际场景:结合Spring Security/Spring Context,从上下文获取
*/
@Override
public Expression getTenantId() {
// 模拟:当前登录租户ID为1(实际开发中替换为真实获取逻辑)
return new LongValue(1L);
}
/**
* 指定租户字段名(与数据库表中字段一致)
*/
@Override
public String getTenantIdColumn() {
return TENANT_ID_FIELD;
}
/**
* 忽略租户拦截的表(如:系统字典表、租户信息表,所有租户共享)
* 避免拦截这些表,导致查询不到数据
*/
@Override
public boolean ignoreTable(String tableName) {
// 示例:忽略t_tenant(租户表)和t_dict(字典表)
return "t_tenant".equals(tableName) || "t_dict".equals(tableName);
}
}
);
// 2. 分页拦截器(放在多租户拦截器之后,先拼接租户条件,再分页)
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
paginationInterceptor.setDbType(DbType.MYSQL);
paginationInterceptor.setOverflow(true);
// 添加拦截器(顺序:多租户 → 分页,至关重要!)
interceptor.addInnerInterceptor(tenantInterceptor);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
2.2.3 优化:ThreadLocal获取当前租户ID
上面的示例中,租户ID是固定的,实际开发中,需从「当前登录用户上下文」获取,通常用ThreadLocal存储(请求链路中共享):
// 1. 租户上下文工具类(企业开发标准封装)
public class TenantContextHolder {
// ThreadLocal:线程隔离,存储当前请求的租户ID
private static final ThreadLocal<Long> TENANT_ID_HOLDER = new ThreadLocal<>();
// 设置租户ID(在拦截器/过滤器中调用,如:登录后设置)
public static void setTenantId(Long tenantId) {
TENANT_ID_HOLDER.set(tenantId);
}
// 获取租户ID(在多租户处理器中调用)
public static Long getTenantId() {
return TENANT_ID_HOLDER.get();
}
// 清除租户ID(请求结束后调用,避免内存泄漏)
public static void clear() {
TENANT_ID_HOLDER.remove();
}
}
// 2. 修改多租户处理器的getTenantId方法
@Override
public Expression getTenantId() {
// 从ThreadLocal获取当前租户ID,若为空则抛出异常(避免无租户ID查询全表)
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
throw new RuntimeException("当前租户ID为空,无法执行查询操作");
}
return new LongValue(tenantId);
}
// 3. 过滤器示例(拦截所有请求,设置租户ID)
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
// 模拟:从请求头获取租户ID(实际开发中,从Token解析登录用户信息)
String tenantIdStr = ((HttpServletRequest) request).getHeader("X-Tenant-Id");
if (StrUtil.isNotBlank(tenantIdStr)) {
Long tenantId = Long.parseLong(tenantIdStr);
TenantContextHolder.setTenantId(tenantId);
}
// 继续执行请求
chain.doFilter(request, response);
} finally {
// 请求结束,清除租户ID,避免ThreadLocal内存泄漏
TenantContextHolder.clear();
}
}
}
2.3 乐观锁拦截器:OptimisticLockerInnerInterceptor
2.3.1 应用场景
解决并发更新冲突(如:多个用户同时修改同一条数据),通过「版本号」机制实现乐观锁,无需手动编写版本号条件。拦截更新SQL,自动拼接「version = ?」条件,并更新版本号。
示例:
原始更新SQL「UPDATE t_user SET username = ? WHERE id = ?」
→ 拦截后「UPDATE t_user SET username = ?, version = version + 1 WHERE id = ? AND version = ?」。
2.3.2 使用示例
// 1. 配置类(添加乐观锁拦截器)
@Configuration
public class MyBatisPlusInterceptorConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 多租户拦截器(最先执行)
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
// 2. 分页拦截器
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setOverflow(true);
// 3. 乐观锁拦截器(放在分页之后,不修改SQL结构,只添加条件)
OptimisticLockerInnerInterceptor optimisticLockerInterceptor = new OptimisticLockerInnerInterceptor();
// 添加顺序:多租户 → 分页 → 乐观锁
interceptor.addInnerInterceptor(tenantInterceptor);
interceptor.addInnerInterceptor(paginationInterceptor);
interceptor.addInnerInterceptor(optimisticLockerInterceptor);
return interceptor;
}
}
// 2. 实体类添加版本号字段(关键)
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private Integer age;
private String deptId;
// 乐观锁版本号字段(必须添加@Version注解)
@Version
@TableField(fill = FieldFill.INSERT) // 插入时自动填充初始值(1)
private Integer version;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
// 3. 自动填充配置(给version、createTime等字段设置默认值)
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 插入时,version默认填充1
strictInsertFill(metaObject, "version", Integer.class, 1);
// 填充创建时间、更新时间
strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时,填充更新时间
strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
// 4. 企业级并发更新示例(Service层)
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
/**
* 并发更新用户信息(乐观锁自动生效)
* @param id 用户ID
* @param newUsername 新用户名
* @return 是否更新成功(失败则说明数据已被其他用户修改)
*/
@Transactional
public boolean updateUserUsername(Long id, String newUsername) {
// 1. 查询用户(获取当前version)
User user = userMapper.selectById(id);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 2. 修改用户名(无需手动处理version)
user.setUsername(newUsername);
// 3. 调用updateById,乐观锁拦截器自动拼接version条件
// 底层SQL:UPDATE t_user SET username=?, version=version+1, update_time=? WHERE id=? AND version=?
int rows = userMapper.updateById(user);
// 4. rows=0 说明更新失败(version不匹配,数据已被修改)
return rows > 0;
}
}
2.4 SQL审计拦截器:自定义审计+IllegalSQLInnerInterceptor
2.4.1 应用场景
企业开发中,SQL安全和可追溯性至关重要:
-
拦截风险SQL(如:无WHERE条件的DELETE/UPDATE,避免全表删除/更新);
-
记录所有SQL的执行日志(执行耗时、执行方法、参数、操作用户),用于问题排查和审计;
-
拦截非法SQL(如:关键字注入、不合理的查询语句)。
这里结合「IllegalSQLInnerInterceptor(MP内置非法SQL拦截)」和「自定义SQL审计拦截器」,实现企业级SQL审计功能。
2.4.2 完整实现(配置+自定义拦截器)
// 1. 自定义SQL审计拦截器(拦截Executor,记录SQL执行日志、慢SQL告警)
@Component
public class SqlAuditInnerInterceptor implements InnerInterceptor {
private static final Logger log = LoggerFactory.getLogger(SqlAuditInnerInterceptor.class);
// 慢SQL阈值(企业可根据实际调整,如:500ms)
private static final long SLOW_SQL_THRESHOLD = 500;
/**
* 拦截查询操作(执行前)
* 可用于阻断非法查询、记录查询前信息
*/
@Override
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// 1. 获取SQL和执行方法(Mapper全路径+方法名,如:com.example.mp.mapper.UserMapper.selectById)
String sql = boundSql.getSql().replaceAll("\\s+", " "); // 格式化SQL(去除多余空格)
String mapperMethod = ms.getId();
// 2. 示例:拦截查询全表且无分页的SQL(避免大数据量查询导致性能问题)
if (ms.getSqlCommandType() == SqlCommandType.SELECT
&& !sql.contains("WHERE")
&& rowBounds == RowBounds.DEFAULT) {
log.error("拦截非法查询SQL:无WHERE条件且无分页,method={}, sql={}", mapperMethod, sql);
throw new SQLException("禁止执行无WHERE条件且无分页的全表查询");
}
log.info("SQL查询准备执行:method={}, sql={}, parameter={}", mapperMethod, sql, parameter);
return true; // 返回true:继续执行SQL;返回false:阻断SQL执行
}
/**
* 拦截查询操作(执行后)
* 可用于记录执行耗时、慢SQL告警
*/
@Override
public void afterQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, Exception e) throws SQLException {
// 1. 获取SQL执行耗时(MP提供的工具类,获取当前SQL的执行时间)
long costTime = System.currentTimeMillis() - boundSql.getParameterObject().hashCode(); // 简化写法,实际用MP提供的耗时工具
String sql = boundSql.getSql().replaceAll("\\s+", " ");
String mapperMethod = ms.getId();
// 2. 慢SQL告警(超过阈值,打印WARN日志,企业可扩展为告警通知)
if (costTime > SLOW_SQL_THRESHOLD) {
log.warn("慢SQL告警:method={}, costTime={}ms, sql={}, parameter={}",
mapperMethod, costTime, sql, parameter);
} else {
log.info("SQL查询执行完成:method={}, costTime={}ms, sql={}",
mapperMethod, costTime, sql);
}
}
/**
* 拦截更新操作(INSERT/UPDATE/DELETE,执行前)
*/
@Override
public boolean willDoUpdate(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql) throws SQLException {
String sql = boundSql.getSql().replaceAll("\\s+", " ");
String mapperMethod = ms.getId();
SqlCommandType commandType = ms.getSqlCommandType();
// 3. 拦截无WHERE条件的DELETE/UPDATE(风险SQL,避免全表修改/删除)
if ((commandType == SqlCommandType.DELETE || commandType == SqlCommandType.UPDATE)
&& !sql.contains("WHERE")) {
log.error("拦截风险SQL:无WHERE条件的{}操作,method={}, sql={}",
commandType.name(), mapperMethod, sql);
throw new SQLException("禁止执行无WHERE条件的" + commandType.name() + "操作");
}
log.info("SQL更新准备执行:method={}, type={}, sql={}, parameter={}",
mapperMethod, commandType.name(), sql, parameter);
return true;
}
/**
* 拦截更新操作(执行后)
* 可用于记录数据变更日志(如:谁修改了什么数据,变更前后内容)
*/
@Override
public void afterUpdate(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql, int updateCount) throws SQLException {
String sql = boundSql.getSql().replaceAll("\\s+", " ");
String mapperMethod = ms.getId();
SqlCommandType commandType = ms.getSqlCommandType();
log.info("SQL更新执行完成:method={}, type={}, sql={}, updateCount={}条",
mapperMethod, commandType.name(), sql, updateCount);
// 扩展:记录数据变更日志(企业级实战,结合ThreadLocal获取操作用户)
String operator = CurrentUserContext.getCurrentUsername(); // 自定义上下文,获取登录用户
log.info("数据变更记录:operator={}, type={}, sql={}, parameter={}",
operator, commandType.name(), sql, parameter);
}
}
// 2. 配置类(添加SQL审计拦截器+内置非法SQL拦截器)
@Configuration
public class MyBatisPlusInterceptorConfig {
@Autowired
private SqlAuditInnerInterceptor sqlAuditInnerInterceptor;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 多租户拦截器
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
// 2. 分页拦截器
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setOverflow(true);
// 3. 乐观锁拦截器
OptimisticLockerInnerInterceptor optimisticLockerInterceptor = new OptimisticLockerInnerInterceptor();
// 4. 内置非法SQL拦截器(拦截关键字注入、不合理SQL)
IllegalSQLInnerInterceptor illegalSqlInterceptor = new IllegalSQLInnerInterceptor();
// 5. 自定义SQL审计拦截器(记录日志、慢SQL、风险SQL)
// 添加顺序(关键):多租户 → 分页 → 乐观锁 → 非法SQL → 审计
interceptor.addInnerInterceptor(tenantInterceptor);
interceptor.addInnerInterceptor(paginationInterceptor);
interceptor.addInnerInterceptor(optimisticLockerInterceptor);
interceptor.addInnerInterceptor(illegalSqlInterceptor);
interceptor.addInnerInterceptor(sqlAuditInnerInterceptor);
return interceptor;
}
}
2.5 动态表名拦截器:DynamicTableNameInnerInterceptor(分表场景)
2.5.1 应用场景
分表场景(如:订单表按时间分表t_order_202401、t_order_202402),拦截SQL,根据业务规则(如:订单时间)动态修改表名,无需手动编写分表SQL。
2.5.2 示例
@Configuration
public class MyBatisPlusInterceptorConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 动态表名拦截器(放在最前面,先修改表名,再执行其他拦截)
DynamicTableNameInnerInterceptor dynamicTableNameInterceptor = new DynamicTableNameInnerInterceptor(
// 表名处理器:根据原始表名和参数,返回动态表名
(sql, tableName) -> {
// 示例1:订单表分表(原始表名t_order,动态拼接月份)
if ("t_order".equals(tableName)) {
// 从参数中获取订单时间(假设参数是OrderQuery对象,包含orderTime)
Object parameter = RequestHolder.getRequestParam(); // 自定义工具,获取请求参数
if (parameter instanceof OrderQuery orderQuery) {
LocalDateTime orderTime = orderQuery.getOrderTime();
// 拼接表名:t_order_202401(格式:yyyyMM)
String month = orderTime.format(DateTimeFormatter.ofPattern("yyyyMM"));
return "t_order_" + month;
}
// 无时间参数,默认查当前月份表
return "t_order_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
// 其他表,返回原始表名(不动态修改)
return tableName;
}
);
// 添加其他拦截器(顺序:动态表名 → 多租户 → 分页 ...)
interceptor.addInnerInterceptor(dynamicTableNameInterceptor);
// 后续添加多租户、分页等拦截器...
return interceptor;
}
}
// 使用示例(Service层)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 动态分表查询:根据订单时间,查询对应月份的订单表
* @param orderQuery 查询条件(包含orderTime)
* @return 订单列表
*/
public List<Order> getOrderByTime(OrderQuery orderQuery) {
// 绑定参数到RequestHolder(供动态表名处理器获取)
RequestHolder.setRequestParam(orderQuery);
LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Order::getOrderTime, orderQuery.getOrderTime());
// 原始SQL:SELECT * FROM t_order WHERE order_time = ?
// 动态表名拦截后:SELECT * FROM t_order_202401 WHERE order_time = ?
List<Order> orders = orderMapper.selectList(queryWrapper);
// 清除参数,避免内存泄漏
RequestHolder.clearRequestParam();
return orders;
}
}
三、进阶实战:自定义SQL拦截器
MP内置拦截器只能满足通用场景,实际开发中,很多个性化需求(如:数据脱敏、数据权限过滤、自定义SQL拼接)需要自定义拦截器。
下面结合2个高频场景,实现完整的自定义拦截器,带你掌握开发流程。
3.1 场景1:数据脱敏拦截器(查询结果脱敏)
3.1.1 需求说明
用户列表查询时,对敏感字段(如:手机号、身份证号)进行脱敏处理,避免敏感信息泄露: - 手机号:1381234 - 身份证号:1101011234
拦截点:ResultSetHandler(结果集封装阶段),在结果返回给Service层之前,修改敏感字段的值。
3.1.2 完整实现(自定义InnerInterceptor)
// 1. 自定义脱敏注解(标记需要脱敏的字段,灵活配置脱敏规则)
@Target({ElementType.FIELD}) // 只作用于字段
@Retention(RetentionPolicy.RUNTIME) // 运行时生效,可通过反射获取
public @interface SensitiveField {
// 脱敏类型(枚举,支持多种脱敏规则)
SensitiveType type();
// 脱敏枚举(企业可扩展更多类型,如:邮箱、地址)
enum SensitiveType {
PHONE, // 手机号
ID_CARD, // 身份证号
USERNAME // 用户名(如:张**)
}
}
// 2. 脱敏工具类(企业级封装,通用工具)
public class SensitiveUtils {
/**
* 手机号脱敏:保留前3位、后4位,中间用****替换
* 示例:13812345678 → 138****5678
*/
public static String desensitizePhone(String phone) {
if (StrUtil.isBlank(phone) || phone.length() != 11) {
return phone;
}
return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
/**
* 身份证号脱敏:保留前6位、后4位,中间用****替换
* 示例:110101199001011234 → 110101****1234
*/
public static String desensitizeIdCard(String idCard) {
if (StrUtil.isBlank(idCard) || (idCard.length() != 18 && idCard.length() != 15)) {
return idCard;
}
return idCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1****$2");
}
/**
* 用户名脱敏:保留第一个字,后面用**替换(2字及以上)
* 示例:张三 → 张**,李四丰 → 李**
*/
public static String desensitizeUsername(String username) {
if (StrUtil.isBlank(username) || username.length() <= 1) {
return username;
}
return username.substring(0, 1) + "**";
}
/**
* 根据脱敏类型,执行对应的脱敏方法
*/
public static String desensitize(String value, SensitiveField.SensitiveType type) {
if (StrUtil.isBlank(value)) {
return value;
}
return switch (type) {
case PHONE -> desensitizePhone(value);
case ID_CARD -> desensitizeIdCard(value);
case USERNAME -> desensitizeUsername(value);
};
}
}
// 3. 自定义脱敏拦截器(实现InnerInterceptor,拦截ResultSetHandler)
@Component
public class SensitiveDataInnerInterceptor implements InnerInterceptor {
/**
* 拦截查询结果,对敏感字段进行脱敏
* 核心:在结果集封装完成后,通过反射修改实体类的敏感字段值
*/
@Override
public void afterQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, Exception e) throws SQLException {
// 1. 判断结果处理器是否为默认的DefaultResultHandler(大部分场景适用)
if (!(resultHandler instanceof DefaultResultHandler defaultResultHandler)) {
return;
}
// 2. 获取查询结果集(DefaultResultHandler的resultList属性,存储查询结果)
List<?> resultList = (List<?>) getFieldValue(defaultResultHandler, "resultList");
if (CollectionUtils.isEmpty(resultList)) {
return;
}
// 3. 遍历结果集,对每个实体类的敏感字段进行脱敏
for (Object result : resultList) {
desensitizeEntity(result);
}
}
/**
* 对单个实体类进行脱敏处理(核心方法)
* @param entity 实体对象(如:User、Order)
*/
private void desensitizeEntity(Object entity) {
// 1. 获取实体类的所有字段(包括父类字段)
Field[] fields = getAllFields(entity.getClass());
for (Field field : fields) {
// 2. 判断字段是否添加了@SensitiveField注解
if (field.isAnnotationPresent(SensitiveField.class)) {
SensitiveField annotation = field.getAnnotation(SensitiveField.class);
try {
// 3. 开启字段访问权限(私有字段需设置)
field.setAccessible(true);
// 4. 获取字段原始值(String类型,脱敏只针对字符串)
Object originalValue = field.get(entity);
if (originalValue == null || !(originalValue instanceof String originalStr)) {
continue;
}
// 5. 根据脱敏类型,执行脱敏操作
String desensitizedValue = SensitiveUtils.desensitize(originalStr, annotation.type());
// 6. 设置脱敏后的值到实体类字段
field.set(entity, desensitizedValue);
} catch (IllegalAccessException ex) {
log.error("敏感数据脱敏失败,实体类:{},字段:{}", entity.getClass().getName(), field.getName(), ex);
} finally {
// 7. 关闭字段访问权限(避免安全问题)
field.setAccessible(false);
}
}
}
}
/**
* 工具方法:获取类的所有字段(包括父类)
*/
private Field[] getAllFields(Class<?> clazz) {
List<Field> fieldList = new ArrayList<>();
// 遍历当前类及其父类,直到Object类
while (clazz != null && clazz != Object.class) {
fieldList.addAll(Arrays.asList(clazz.getDeclaredFields()));
clazz = clazz.getSuperclass();
}
return fieldList.toArray(new Field[0]);
}
/**
* 工具方法:通过反射获取对象的指定字段值(如:DefaultResultHandler的resultList)
*/
private Object getFieldValue(Object obj, String fieldName) {
try {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(obj);
} catch (NoSuchFieldException | IllegalAccessException ex) {
log.error("获取字段值失败,对象:{},字段名:{}", obj.getClass().getName(), fieldName, ex);
return null;
}
}
}
// 4. 实体类使用@SensitiveField注解(标记敏感字段)
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
// 用户名脱敏(类型:USERNAME)
@SensitiveField(type = SensitiveField.SensitiveType.USERNAME)
private String username;
private String password;
private Integer age;
private String deptId;
// 手机号脱敏(类型:PHONE)
@SensitiveField(type = SensitiveField.SensitiveType.PHONE)
private String phone;
// 身份证号脱敏(类型:ID_CARD)
@SensitiveField(type = SensitiveField.SensitiveType.ID_CARD)
private String idCard;
@Version
private Integer version;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
// 5. 配置类添加脱敏拦截器(顺序:最后执行,不影响其他SQL逻辑)
@Configuration
public class MyBatisPlusInterceptorConfig {
@Autowired
private SensitiveDataInnerInterceptor sensitiveDataInnerInterceptor;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 动态表名(可选)
// 2. 多租户
// 3. 分页
// 4. 乐观锁
// 5. 非法SQL
// 6. SQL审计
// 7. 数据脱敏(最后执行,在结果返回前脱敏)
interceptor.addInnerInterceptor(...); // 前面的拦截器
interceptor.addInnerInterceptor(sensitiveDataInnerInterceptor);
return interceptor;
}
}
// 6. 测试效果(Service层)
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public List<User> getUserList() {
// 查询所有用户,脱敏拦截器自动生效
List<User> userList = userMapper.selectList(null);
// 输出结果:手机号13812345678 → 138****5678,身份证号110101199001011234 → 110101****1234
userList.forEach(user -> log.info("脱敏后用户:{}", user));
return userList;
}
}
3.2 场景2:数据权限拦截器(行级权限控制)
3.2.1 需求说明
企业级系统中,不同角色的用户只能查看自己权限范围内的数据:
-
管理员:查看所有部门数据;
-
部门经理:查看本部门所有数据;
-
普通员工:只能查看自己的数据。
拦截点:StatementHandler.prepare(SQL准备阶段),通过解析SQL,自动拼接数据权限条件(如:dept_id = ? 或 create_user_id = ?),无需手动在每个查询中添加条件。
3.2.2 完整实现(结合JSqlParser解析SQL)
// 1. 权限常量(企业级权限体系,结合Spring Security)
public class PermissionConstant {
// 角色类型
public static final String ROLE_ADMIN = "ADMIN"; // 管理员
public static final String ROLE_DEPT_MANAGER = "DEPT_MANAGER"; // 部门经理
public static final String ROLE_EMPLOYEE = "EMPLOYEE"; // 普通员工
// 数据权限字段(数据库表中对应的字段)
public static final String DEPT_ID_FIELD = "dept_id"; // 部门ID字段
public static final String CREATE_USER_ID_FIELD = "create_user_id"; // 创建人ID字段
}
// 2. 用户上下文工具类(获取当前登录用户的角色、部门ID、用户ID)
public class CurrentUserContext {
// ThreadLocal存储当前登录用户信息
private static final ThreadLocal<UserInfo> CURRENT_USER_HOLDER = new ThreadLocal<>();
// 用户信息封装(实际开发中,从Token/Spring Security获取)
@Data
public static class UserInfo {
private Long userId; // 用户ID
private String username; // 用户名
private String role; // 角色(ADMIN/DEPT_MANAGER/EMPLOYEE)
private Long deptId; // 部门ID
}
// 设置当前用户信息(登录后调用)
public static void setCurrentUser(UserInfo userInfo) {
CURRENT_USER_HOLDER.set(userInfo);
}
// 获取当前用户信息
public static UserInfo getCurrentUser() {
return CURRENT_USER_HOLDER.get();
}
// 清除当前用户信息(请求结束后调用)
public static void clearCurrentUser() {
CURRENT_USER_HOLDER.remove();
}
}
// 3. 自定义数据权限拦截器(核心,解析SQL并拼接权限条件)
@Component
public class DataPermissionInnerInterceptor implements InnerInterceptor {
private static final Logger log = LoggerFactory.getLogger(DataPermissionInnerInterceptor.class);
/**
* 拦截SQL准备阶段,拼接数据权限条件
* 核心:通过JSqlParser解析SQL,修改WHERE条件,添加权限过滤
*/
@Override
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) throws SQLException {
// 1. 获取当前登录用户信息(无用户信息,不拦截,如:匿名访问)
CurrentUserContext.UserInfo currentUser = CurrentUserContext.getCurrentUser();
if (currentUser == null) {
return;
}
// 2. 获取StatementHandler的BoundSql(包含原始SQL和参数)
BoundSql boundSql = sh.getBoundSql();
String originalSql = boundSql.getSql().replaceAll("\\s+", " "); // 格式化SQL
String mappedStatementId = getMappedStatementId(sh); // 获取Mapper方法全路径
// 3. 忽略不需要数据权限控制的方法(如:管理员查询接口、字典查询接口)
if (ignoreDataPermission(mappedStatementId)) {
log.info("忽略数据权限控制,method={}", mappedStatementId);
return;
}
// 4. 解析SQL(使用JSqlParser,MP自定义SQL修改必备)
try {
// 解析SQL为Statement对象(支持SELECT/UPDATE/DELETE,此处只处理查询)
Statement statement = CCJSqlParserUtil.parse(originalSql);
if (!(statement instanceof Select select)) {
// 非查询SQL(如更新/删除),暂不处理(可根据需求扩展)
return;
}
// 5. 获取SQL的WHERE子句,拼接数据权限条件
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect plainSelect) {
// 构建数据权限条件表达式(核心逻辑)
Expression permissionCondition = buildPermissionCondition(currentUser);
if (permissionCondition == null) {
return;
}
// 拼接条件:原有WHERE条件 + AND 权限条件(无原有条件则直接设为WHERE权限条件)
Expression where = plainSelect.getWhere();
if (where != null) {
// 原有WHERE条件存在,拼接AND
plainSelect.setWhere(new AndExpression(where, permissionCondition));
} else {
// 原有WHERE条件不存在,直接设置权限条件为WHERE子句
plainSelect.setWhere(permissionCondition);
}
// 6. 重新生成修改后的SQL,设置回BoundSql
String modifiedSql = select.toString().replaceAll("\\s+", " ");
Field sqlField = boundSql.getClass().getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, modifiedSql);
log.info("数据权限SQL修改完成:originalSql={}, modifiedSql={}, userRole={}", originalSql, modifiedSql, currentUser.getRole());
}
} catch (Exception ex) {
log.error("数据权限拦截器解析SQL失败,originalSql={}, method={}", originalSql, mappedStatementId, ex);
// 解析失败,可选择抛出异常阻断SQL执行,或不处理(根据企业容错策略调整)
throw new SQLException("数据权限控制异常,请联系管理员");
}
}
/**
* 构建数据权限条件表达式(根据用户角色生成不同条件)
* @param currentUser 当前登录用户信息
* @return 权限条件表达式(JSqlParser的Expression对象)
*/
private Expression buildPermissionCondition(CurrentUserContext.UserInfo currentUser) {
String role = currentUser.getRole();
Long userId = currentUser.getUserId();
Long deptId = currentUser.getDeptId();
// 根据角色生成不同的权限条件
return switch (role) {
// 1. 管理员:无权限条件(查看所有数据)
case PermissionConstant.ROLE_ADMIN -> null;
// 2. 部门经理:查看本部门所有数据(dept_id = 当前部门ID)
case PermissionConstant.ROLE_DEPT_MANAGER ->
new EqualsTo(
new Column(PermissionConstant.DEPT_ID_FIELD), // 数据库字段:dept_id
new LongValue(deptId) // 值:当前用户的部门ID
);
// 3. 普通员工:只能查看自己创建的数据(create_user_id = 当前用户ID)
case PermissionConstant.ROLE_EMPLOYEE ->
new EqualsTo(
new Column(PermissionConstant.CREATE_USER_ID_FIELD), // 数据库字段:create_user_id
new LongValue(userId) // 值:当前用户ID
);
// 其他角色:无权限(阻断查询,可根据需求调整为抛出异常)
default -> new EqualsTo(new Column("1"), new Column("0"));
};
}
/**
* 获取Mapper方法全路径(如:com.example.mp.mapper.UserMapper.selectList)
* @param sh StatementHandler对象
* @return Mapper方法全路径
*/
private String getMappedStatementId(StatementHandler sh) throws NoSuchFieldException, IllegalAccessException {
// 通过反射获取StatementHandler的delegate属性(实际是RoutingStatementHandler的delegate)
Field delegateField = sh.getClass().getDeclaredField("delegate");
delegateField.setAccessible(true);
StatementHandler delegate = (StatementHandler) delegateField.get(sh);
// 获取MappedStatement对象,进而获取方法ID
Field mappedStatementField = delegate.getClass().getDeclaredField("mappedStatement");
mappedStatementField.setAccessible(true);
MappedStatement mappedStatement = (MappedStatement) mappedStatementField.get(delegate);
return mappedStatement.getId();
}
/**
* 忽略不需要数据权限控制的方法(企业可根据实际接口调整)
* @param mappedStatementId Mapper方法全路径
* @return true:忽略,false:需要控制
*/
private boolean ignoreDataPermission(String mappedStatementId) {
// 示例:忽略字典表、租户表、管理员专用接口的查询
return mappedStatementId.contains("DictMapper") ||
mappedStatementId.contains("TenantMapper") ||
mappedStatementId.contains("AdminMapper");
}
}
// 4. 配置类添加数据权限拦截器(顺序:在分页拦截器之前,先拼接权限条件再分页)
@Configuration
public class MyBatisPlusInterceptorConfig {
@Autowired
private DataPermissionInnerInterceptor dataPermissionInnerInterceptor;
@Autowired
private SensitiveDataInnerInterceptor sensitiveDataInnerInterceptor;
@Autowired
private SqlAuditInnerInterceptor sqlAuditInnerInterceptor;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 拦截器顺序(至关重要,错误顺序会导致SQL解析失败)
// 1. 动态表名拦截器(可选)
// 2. 多租户拦截器(先隔离租户数据)
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
// 3. 数据权限拦截器(再拼接行级权限条件)
interceptor.addInnerInterceptor(tenantInterceptor);
interceptor.addInnerInterceptor(dataPermissionInnerInterceptor);
// 4. 分页拦截器(最后拼接分页条件)
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setOverflow(true);
interceptor.addInnerInterceptor(paginationInterceptor);
// 5. 乐观锁拦截器
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 6. 内置非法SQL拦截器
interceptor.addInnerInterceptor(new IllegalSQLInnerInterceptor());
// 7. SQL审计拦截器
interceptor.addInnerInterceptor(sqlAuditInnerInterceptor);
// 8. 数据脱敏拦截器(最后处理结果)
interceptor.addInnerInterceptor(sensitiveDataInnerInterceptor);
return interceptor;
}
}
// 5. 测试效果(不同角色用户查询对比)
@SpringBootTest
public class DataPermissionTest {
@Autowired
private UserMapper userMapper;
// 测试管理员角色(查看所有数据)
@Test
public void testAdminPermission() {
// 模拟管理员登录,设置用户上下文
CurrentUserContext.UserInfo admin = new CurrentUserContext.UserInfo();
admin.setUserId(1L);
admin.setRole(PermissionConstant.ROLE_ADMIN);
CurrentUserContext.setCurrentUser(admin);
// 原始SQL:SELECT * FROM t_user
// 拦截后SQL:SELECT * FROM t_user(无权限条件)
List<User> allUser = userMapper.selectList(null);
log.info("管理员查看所有用户:{}条", allUser.size());
// 清除上下文
CurrentUserContext.clearCurrentUser();
}
// 测试部门经理角色(查看本部门数据)
@Test
public void testDeptManagerPermission() {
// 模拟部门经理登录(部门ID=2)
CurrentUserContext.UserInfo deptManager = new CurrentUserContext.UserInfo();
deptManager.setUserId(2L);
deptManager.setRole(PermissionConstant.ROLE_DEPT_MANAGER);
deptManager.setDeptId(2L);
CurrentUserContext.setCurrentUser(deptManager);
// 原始SQL:SELECT * FROM t_user WHERE age > 25
// 拦截后SQL:SELECT * FROM t_user WHERE age > 25 AND dept_id = 2
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.gt(User::getAge, 25);
List<User> deptUser = userMapper.selectList(queryWrapper);
log.info("部门经理查看本部门用户:{}条", deptUser.size());
CurrentUserContext.clearCurrentUser();
}
// 测试普通员工角色(查看自己创建的数据)
@Test
public void testEmployeePermission() {
// 模拟普通员工登录(用户ID=3)
CurrentUserContext.UserInfo employee = new CurrentUserContext.UserInfo();
employee.setUserId(3L);
employee.setRole(PermissionConstant.ROLE_EMPLOYEE);
employee.setDeptId(2L);
CurrentUserContext.setCurrentUser(employee);
// 原始SQL:SELECT * FROM t_user
// 拦截后SQL:SELECT * FROM t_user WHERE create_user_id = 3
List<User> selfUser = userMapper.selectList(null);
log.info("普通员工查看自己创建的用户:{}条", selfUser.size());
CurrentUserContext.clearCurrentUser();
}
}
3.3 自定义拦截器开发总结
结合上面2个场景,总结自定义MP SQL拦截器的核心开发流程(必记),确保开发的拦截器规范、可复用、无隐患:
-
确定拦截点:根据需求选择对应的拦截点(Executor/StatementHandler/ParameterHandler/ResultSetHandler),比如修改SQL选StatementHandler,处理结果选ResultSetHandler。
-
实现InnerInterceptor接口:重写对应方法(如beforePrepare、afterQuery),无需关注原生MyBatis的代理逻辑,MP已封装好规范。
-
核心逻辑实现:修改SQL用JSqlParser解析,处理参数/结果用反射,注意异常捕获和容错(避免解析失败导致整个SQL执行异常)。
-
注册到MybatisPlusInterceptor:必须将自定义拦截器添加到拦截器容器中,且严格控制顺序(错误顺序会导致功能失效)。
-
测试验证:覆盖不同场景(正常情况、异常情况、边界情况),确保拦截逻辑生效,不影响原有SQL执行。
关键提醒:自定义拦截器尽量单一职责(一个拦截器只处理一个功能,如脱敏拦截器只做脱敏,数据权限拦截器只做权限控制),便于维护和扩展。
四、避坑指南
MP SQL拦截器虽然强大,但如果使用不当,会导致SQL性能退化、业务逻辑异常、数据安全问题,结合企业开发真实案例,总结5个高频坑点,附带解决方案,帮你避坑。
4.1 坑点1:拦截器顺序错误(最常见,直接导致功能失效)
问题现象
多拦截器协同工作时,功能失效:比如多租户拦截器放在分页拦截器之后,导致分页SQL拼接在租户条件之前,出现SQL语法错误;数据权限拦截器放在分页之后,导致权限条件未生效,查询到全表数据。
解决方案(必记顺序)
拦截器添加顺序遵循「先修改SQL结构,后补充条件,最后处理结果」的原则,标准顺序(从先到后):
-
动态表名拦截器(DynamicTableNameInnerInterceptor):最先修改表名,后续拦截器基于修改后的表名处理。
-
多租户拦截器(TenantLineInnerInterceptor):先隔离租户数据,避免跨租户查询。
-
数据权限拦截器(自定义):再拼接行级权限条件,进一步过滤数据。
-
分页拦截器(PaginationInnerInterceptor):基于前面的条件,拼接分页语句(最后修改SQL结构)。
-
乐观锁拦截器(OptimisticLockerInnerInterceptor):只修改更新SQL的条件,不影响SQL结构。
-
非法SQL拦截器(IllegalSQLInnerInterceptor):拦截风险SQL,避免安全问题。
-
SQL审计拦截器(自定义):记录SQL执行日志,用于排查问题。
-
数据脱敏拦截器(自定义):最后处理查询结果,不影响SQL执行。
记忆口诀:表名→租户→权限→分页→乐观锁→非法→审计→脱敏。
4.2 坑点2:滥用拦截器,导致SQL性能退化
问题现象
自定义拦截器中做复杂操作(如:频繁反射、循环遍历大量数据、远程调用),导致SQL执行耗时大幅增加,比如脱敏拦截器中遍历十万条结果集,导致接口响应超时。
解决方案
-
拦截器逻辑尽量轻量化:避免复杂计算、远程调用,反射操作尽量缓存(如缓存实体类字段信息)。
-
针对性拦截:避免拦截所有SQL,通过ignoreTable、ignoreMethod等方式,忽略不需要拦截的表和方法。
-
结果集处理优化:批量处理数据,避免循环遍历单条数据,比如脱敏时批量处理列表数据。
4.3 坑点3:SQL解析不严谨,导致语法错误
问题现象
自定义拦截器修改SQL时,未考虑复杂SQL场景(如:子查询、联表查询、GROUP BY/HAVING子句),导致拼接条件后出现SQL语法错误。
解决方案
-
使用JSqlParser解析SQL:避免手动拼接字符串修改SQL,JSqlParser能正确解析复杂SQL结构,确保拼接条件的位置正确。
-
覆盖复杂SQL场景测试:测试时包含子查询、联表查询、分页+多条件等场景,确保SQL解析无误。
-
添加异常兜底:解析SQL失败时,抛出明确异常,便于排查问题,避免模糊报错。
4.4 坑点4:忽略ThreadLocal内存泄漏
问题现象
自定义拦截器中使用ThreadLocal存储上下文(如:租户ID、当前用户信息),但请求结束后未清除,导致线程池中的线程持有过期数据,出现数据错乱、内存泄漏。
解决方案
-
强制清除ThreadLocal数据:在过滤器/拦截器的finally块中,调用ThreadLocal的remove()方法,确保请求结束后清除数据。
-
使用Spring提供的RequestContextHolder:结合请求生命周期,自动清理上下文数据,避免手动管理。
4.5 坑点5:未忽略系统表/公共表,导致业务异常
问题现象
多租户、数据权限拦截器未忽略系统表(如:字典表、租户表、日志表),导致查询系统表时被拼接租户/权限条件,查询不到数据,出现系统异常。
解决方案
-
统一配置忽略表:在拦截器中通过ignoreTable方法,明确忽略系统表、公共表,避免拦截。
-
按方法忽略:对于部分不需要拦截的接口(如:管理员查询系统字典),通过方法名匹配忽略拦截。