Mybatis-Plus多租户插件&属性自动赋值方式
Mybatis-Plus多租户插件TenantLineInnerInterceptor通过拦截器自动拼接租户字段条件实现数据隔离。其核心TenantLineHandler接口需实现获取租户ID、指定租户字段名(默认tenant_id)及忽略表逻辑。自定义处理器从ThreadLocalUtil获取租户ID,设置字段为archive_company_id,并通过
1、Mybatis-Plus 多租户插件
TenantLineInnerInterceptor 是 MyBatis-Plus 提供的一个插件,专门用来做多租户的数据隔离。说白了,它就是一道防线——确保每个租户只能看到自己的数据,不会越界。从本质上讲,这其实就是一个拦截器,在执行的 增删改查 语句上自动追加租户字段的条件。
1.1、属性介绍
这个拦截器的核心是 tenantLineHandler,一个 TenantLineHandler 接口的实例,它决定了租户逻辑怎么走。
| 属性名 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| tenantLineHandler | TenantLineHandler | 租户处理器( TenantId 行级 ) |
TenantLineHandler 接口里定义了几个关键方法:
public interface TenantLineHandler {
/**
* 获取租户 ID 值表达式,只支持单个 ID 值
*
* @return 租户 ID 值表达式
*/
Expression getTenantId();
/**
* 获取租户字段名
* 默认字段名叫: tenant_id
*
* @return 租户字段名
*/
default String getTenantIdColumn() {
return "tenant_id";//默认
}
/**
* 根据表名判断是否忽略拼接多租户条件
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
default boolean ignoreTable(String tableName) {
return false;
}
/**
* 忽略插入租户字段逻辑
*
* @param columns 插入字段
* @param tenantIdColumn 租户 ID 字段
* @return
*/
default boolean ignoreInsert(List columns, String tenantIdColumn) {
return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));
}
}
1.2、使用多租户插件
比如我们有一张表 biz_archive_common,结构如下:
-- security_manager.biz_archive_common definition CREATE TABLE `biz_archive_common` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '数据主键ID', `title_name` varchar(255) DEFAULT NULL COMMENT '题名', `secrecy_level_id` bigint(20) DEFAULT NULL COMMENT '密级id', `archive_num` varchar(510) DEFAULT NULL COMMENT '档号(照片号)', `roll_num` varchar(31) DEFAULT NULL COMMENT '案卷号(册号/带号)', `abandon` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否废弃 默认 0false/1true', `del` varchar(200) NOT NULL DEFAULT '0' COMMENT '是否删除 默认 0false/1true', `create_user` varchar(31) DEFAULT NULL COMMENT '创建者账户', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_user` varchar(31) DEFAULT NULL COMMENT '更新者账户', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `archive_company_id` bigint(20) DEFAULT NULL COMMENT '全宗单位ID', PRIMARY KEY (`id`) USING BTREE, KEY `indexArchiveCompanyId` (`archive_company_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='档案共有信息表';

先加上 ma ven 依赖和 yml 配置,这些是基础,不细说了。
ma ven
org.springframework.boot spring-boot-starter-web com.baomidou mybatis-plus-spring-boot3-starter 3.5.6 com.mysql mysql-connector-j runtime com.alibaba druid-spring-boot-starter 1.2.8 org.springframework.boot spring-boot-starter-test test org.projectlombok lombok
yml
server:
port: 8001
#address: 127.0.0.1
#spring数据源配置
spring:
application:
name: token #项目名
# 数据源
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security_manager?serverTimezone=GMT%2B8&useUnicode=true&useSSL=false&characterEncoding=utf-8
username: root
password: root
druid:
initial-size: 20
min-idle: 20
max-active: 100
max-wait: 10000
time-between-eviction-0runs-millis: 60000
min-evictable-idle-time-millis: 30000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: true
test-on-return: true
# mybatis-plus配置
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
configuration:
map-underscore-to-camel-case: true # 数据库下划线自动转驼峰标示关闭
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #日志配置
mapper-locations: classpath*:/mapper/**/*.xml
然后需要一个线程级别的工具类来传递租户 ID,这里用 ThreadLocalUtil。
package cn.js.util;
import ja va.util.HashMap;
import ja va.util.Map;
/**
* Description:
*
* @Author Js
* @Create 2024-11-17 14:12
* @Version 1.0
*/
public class ThreadLocalUtil {
//1 初始化TreadLocal
private static ThreadLocal
关键部分来了:实现租户处理器并注入到拦截器里。注意这里的 getTenantId() 是从 ThreadLocalUtil 中取的租户ID,然后通过 LongValue 封装。同时指定了租户字段名为 archive_company_id,并且通过 ignoreTable 方法让某些表跳过租户条件。
package cn.js.config;
import cn.js.util.ThreadLocalUtil;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.schema.Column;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import ja va.util.ArrayList;
import ja va.util.List;
/**
* @Author Js
* @Description
* @Date 2024-11-15 21:34
* @Version 1.0
**/
@Configuration
@AutoConfigureBefore(MybatisPlusAutoConfiguration.class)
public class PaginationInterceptorConfig {
@Bean
public MybatisPlusInterceptor addMybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler()));
return interceptor;
}
private class TenantLineHandler implements com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler{
/**
* 获取当前租户 ID。
*/
@Override
public Expression getTenantId() {
Object id = ThreadLocalUtil.get("id");
Long tenantId=Long.valueOf(String.valueOf(id));
// 返回租户ID的表达式,LongValue 是 JSQLParser 中表示 bigint 类型的 class
return new LongValue(tenantId);
}
/**
* 表结构中那个字段用于拼接多租户条件
*/
@Override
public String getTenantIdColumn() {
return "archive_company_id";
}
/**
* 默认返回false:表示所有表都需要拼接多租户条件
* tableName:表名称
*/
@Override
public boolean ignoreTable(String tableName) {
//如果那些表不需要拼接多租户条件,
List tableList = new ArrayList<>();
tableList.add("sql_version");
tableList.add("User");
tableList.add("Kf");
if(tableList.contains(tableName)){
//如果不需要添加的表名称在list中,就返回false,不用拼接租户条件
return true;
}
return false;
}
/**
* 获取租户 ID 字段名。
*/
@Override
public boolean ignoreInsert(List columns, String tenantIdColumn) {
return com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler.super.ignoreInsert(columns, tenantIdColumn);
}
}
}
写个简单的 Controller 测试一下,设置 ThreadLocalUtil.set("id",1645),然后查询全部数据:
package cn.js.controller;
import cn.js.domain.BizArchiveCommon;
import cn.js.service.BizArchiveCommonService;
import cn.js.util.ThreadLocalUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ja vax.annotation.Resource;
import ja va.util.List;
/**
* @Author Js
* @Description
* @Date 2024-11-15 21:26
* @Version 1.0
**/
@RequestMapping("/common")
@RestController
public class BizArchiveCommonController {
@Resource
private BizArchiveCommonService bizArchiveCommonService;
@GetMapping("/getAll")
public List getAll() {
ThreadLocalUtil.set("id",1645);
List archiveCommons = bizArchiveCommonService.list();
return archiveCommons;
}
}
对应的 domain、service、mapper 也比较常规:
domain
package cn.js.domain;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import ja va.time.LocalDateTime;
/**
* @Author Js
* @Description
* @Date 2024-11-15 21:22
* @Version 1.0
**/
@Data
@TableName(value="biz_archive_common")
public class BizArchiveCommon {
private Long id;
private String titleName;
private Long secrecyLevelId;
private String archiveNum;
private String rollNum;
@TableLogic(value = "false", delval = "true")
private Boolean abandon;
/**
* 创建人
*/
@TableField(fill = FieldFill.INSERT)
private String createUser;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 修改人
*/
@TableField(fill = FieldFill.UPDATE)
private String updateUser;
/**
* 修改时间
*/
@TableField(fill = FieldFill.UPDATE)
private LocalDateTime updateTime;
/**
* 是否逻辑删除,true:删除 false:未删除
*/
@TableLogic(value = "false", delval = "true")
private Boolean del;
/**
* 全宗单位id
*/
private Long archiveCompanyId;
}
service & ServiceImpl
package cn.js.service; import cn.js.domain.BizArchiveCommon; import com.baomidou.mybatisplus.extension.service.IService; public interface BizArchiveCommonService extends IService{ } package cn.js.service.impl; import cn.js.domain.BizArchiveCommon; import cn.js.mapper.BizArchiveCommonMapper; import cn.js.service.BizArchiveCommonService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.stereotype.Service; /** * @Author Js * @Description * @Date 2024-11-15 21:27 * @Version 1.0 **/ @Service public class BizArchiveCommonServiceImpl extends ServiceImpl implements BizArchiveCommonService { }
mapper
package cn.js.mapper; import cn.js.domain.BizArchiveCommon; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface BizArchiveCommonMapper extends BaseMapper{ }
运行日志可以清楚地看到,SELECT 语句自动加上了 WHERE archive_company_id = 1645:
JDBC Connection [HikariProxyConnection@639980080 wrapping com.mysql.cj.jdbc.ConnectionImpl@7c12090] will not be managed by Spring ==> Preparing: SELECT id, title_name, secrecy_level_id, archive_num, roll_num, abandon, del, create_user, create_time, update_user, update_time, archive_company_id FROM biz_archive_common WHERE archive_company_id = 1645 ==> Parameters: <== Total: 0 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@38e12bd4]

那如果用自定义的 mapper.xml 呢?也完全没问题——只要表名没有被 ignoreTable 排除,插件照样会追加租户条件。
@RequestMapping("/common")
@RestController
public class BizArchiveCommonController {
@Resource
private BizArchiveCommonService bizArchiveCommonService;
@GetMapping("/getOne")
public BizArchiveCommon getOnes() {
ThreadLocalUtil.set("id",1645);
BizArchiveCommon archiveCommon = bizArchiveCommonService.getones();
return archiveCommon;
}
}
日志:
JDBC Connection [HikariProxyConnection@1523711906 wrapping com.mysql.cj.jdbc.ConnectionImpl@6db5719b] will not be managed by Spring ==> Preparing: SELECT * FROM biz_archive_common WHERE id = 1 AND archive_company_id = 1645 ==> Parameters: <== Total: 0 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@70ef05c0]

1.3、不使用多租户插件
有时候某个 SQL 确实不需要租户条件,比如跨租户统计。这时可以在对应的 Mapper 方法上加注解 @InterceptorIgnore(tenantLine = "true"),让插件对该方法放行。
package cn.js.mapper; import cn.js.domain.BizArchiveCommon; import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface BizArchiveCommonMapper extends BaseMapper{ @InterceptorIgnore(tenantLine = "true") BizArchiveCommon ones(); }

2、实体对象的属性自动赋值

很多表都有 create_time、create_user、update_time、update_user 这类公共字段。如果能自动填充,就不需要在每次新增或修改时手动赋值了。MyBatis-Plus 的自动填充功能正好解决这个问题。
使用
1. 定义实体类
在实体类的字段上添加 @TableField 注解,通过 fill 属性指定填充策略:
public class User {
@TableField(fill = FieldFill.INSERT)
private String createTime;
@TableField(fill = FieldFill.UPDATE)
private String updateTime;
// 其他字段...
}
2. 实现 MetaObjectHandler
创建一个类实现 MetaObjectHandler 接口,重写 insertFill 和 updateFill 方法,在里面设置具体值:
package cn.js.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import ja va.time.LocalDateTime;
/**
* Description:
*
* @Author Js
* @Create 2024-11-17 15:39
* @Version 1.0
*/
@Component
@Slf4j
public class MetaObjectHandlerConfig implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.info("开始插入填充...");
this.strictInsertFill(metaObject,"createTime", LocalDateTime.class,LocalDateTime.now());
this.strictInsertFill(metaObject,"createUser", String.class,"张三");
}
@Override
public void updateFill(MetaObject metaObject) {
log.info("开始更新填充...");
this.strictUpdateFill(metaObject,"updateTime", LocalDateTime.class,LocalDateTime.now());
this.strictUpdateFill(metaObject,"updateUser", String.class,"王五");
}
}
3. 配置自动填充处理器
确保这个实现类被 Spring 管理(加了 @Component 就行了),MyBatis-Plus 会自动发现它。
注意事项
- 自动填充是直接给实体类的属性设置值,如果属性本身已经有值,默认填充策略不会覆盖。
- 如果没填,入库时就是
null,所以最好搭配数据库的默认值或非空约束。 - 字段必须声明
@TableField(fill = ...)注解,否则填充器不会处理。 strictInsertFill和strictUpdateFill会根据字段名、类型区分,推荐使用;如果不需区分,可以用fillStrategy统一处理。- 在
update(T entity, Wrapper时,updateWrapper) entity不能为空,否则填充会失效;而update(Wrapper根本不会触发自动填充,需要手动处理。updateWrapper)
4. 测试
package cn.js.controller;
import cn.js.domain.BizArchiveCommon;
import cn.js.service.BizArchiveCommonService;
import cn.js.util.ThreadLocalUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import ja vax.annotation.Resource;
import ja va.util.List;
/**
* @Author Js
* @Description
* @Date 2024-11-15 21:26
* @Version 1.0
**/
@RequestMapping("/common")
@RestController
public class BizArchiveCommonController {
@Resource
private BizArchiveCommonService bizArchiveCommonService;
@GetMapping("/sa ve")
public Boolean sa ve() {
ThreadLocalUtil.set("id",1645);
BizArchiveCommon bizArchiveCommon = new BizArchiveCommon();
bizArchiveCommon.setTitleName("这是新增的");
bizArchiveCommon.setSecrecyLevelId(123L);
bizArchiveCommon.setArchiveNum("8080-25201-38245");
bizArchiveCommon.setRollNum("25201");
bizArchiveCommon.setAbandon(false);
boolean archiveCommon = bizArchiveCommonService.sa ve(bizArchiveCommon);
return archiveCommon;
}
@GetMapping("/update")
public Boolean update() {
ThreadLocalUtil.set("id",1645);
BizArchiveCommon bizArchiveCommon = new BizArchiveCommon();
bizArchiveCommon.setId(1L);
bizArchiveCommon.setTitleName("这是新增的,进行修改!");
bizArchiveCommon.setSecrecyLevelId(123L);
bizArchiveCommon.setArchiveNum("8080-25201-38245");
bizArchiveCommon.setRollNum("25201");
bizArchiveCommon.setAbandon(false);
boolean b = bizArchiveCommonService.updateById(bizArchiveCommon);
return b;
}
}
新增时的日志:
JDBC Connection [HikariProxyConnection@1186756471 wrapping com.mysql.cj.jdbc.ConnectionImpl@22ef7a2e] will not be managed by Spring ==> Preparing: INSERT INTO biz_archive_common (id, title_name, secrecy_level_id, archive_num, roll_num, abandon, create_user, create_time, archive_company_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1645) ==> Parameters: 1858059911531872257(Long), 这是新增的(String), 123(Long), 8080-25201-38245(String), 25201(String), false(Boolean), 张三(String), 2024-11-17T16:09:38.650358300(LocalDateTime) <== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7b266740]

修改时的日志:
JDBC Connection [HikariProxyConnection@724111921 wrapping com.mysql.cj.jdbc.ConnectionImpl@5db6c17a] will not be managed by Spring ==> Preparing: UPDATE biz_archive_common SET title_name = ?, secrecy_level_id = ?, archive_num = ?, roll_num = ?, abandon = ?, update_user = ?, update_time = ? WHERE id = ? AND del = false AND archive_company_id = 1645 ==> Parameters: 这是新增的,进行修改!(String), 123(Long), 8080-25201-38245(String), 25201(String), false(Boolean), 王五(String), 2024-11-17T16:22:59.641094300(LocalDateTime), 1(Long) <== Updates: 1 Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5390448d]

总结
从上面的示例可以看出,多租户插件和自动填充功能组合使用,能让数据隔离和公共字段处理变得非常优雅。核心思路就是:先通过拦截器自动拼上租户条件,再通过填充器自动注入创建/修改信息,业务代码里只需关注业务逻辑本身。当然,实际项目里还可以结合 ThreadLocal 或请求上下文来动态传递租户 ID,这里的实现只是一个简化版本,供大家参考。
Windows 10 是一款微软推出的经典操作系统,拥有硬件兼容性与多任务处理能力。它更偏向把系统状态查看和常用调节动作放在一起,适合需要持续观察和微调设备状态的场景。
极度公式是一款跨平台专业LaTeX公式识别编辑软件,支持OCR公式识别和多平台编辑。和使用说明,避免使用,享受完整功能与稳定支持。做扫描整理、文字提取和表格转换时,它能把识别后的处理步骤接得更顺,资料录入这类场景会省下不少时间。
















