Mybatis-plus多租户插件实现数据隔离方案分享

多租户是什么:
多租户:多租户技术又称多重租赁技术,是一种软件即服务的软件服务架构(简称SaaS)。同一个系统开放给多个组织/用户使用,每个组织/用户需要进行数据隔离,并且每个组织/用户可以自定义自己租用系统的个性化配置。使用多重租赁技术还有PaaS,IaaS等。

Mybatis-plus多租户插件实现原理:
注意:多租户 != 权限过滤,不要乱用,租户之间是完全隔离的!!!启用多租户后所有执行的method的sql都会进行处理.

自写的sql请按规范书写(sql涉及到多个表的每个表都要给别名,特别是 inner join 的要写标准的 inner join)

TenantLineInnerInterceptor 租户数据隔离内置拦截器(插件)

属性名
tenantLineHandler
类型
TenantLineHandler
描述
租户处理器( TenantId 行级 )

public interface TenantLineHandler {

    /**
     * 获取租户 ID 值表达式,只支持单个 ID 值
     * <p>
     *
     * @return 租户 ID 值表达式
     */
    Expression getTenantId();

    /**
     * 获取租户字段名
     * <p>
     * 默认字段名叫: tenant_id
     *
     * @return 租户字段名
     */
    default String getTenantIdColumn() {
        return "tenant_id";
    }

    /**
     * 根据表名判断是否忽略拼接多租户条件
     * <p>
     * 默认都要进行解析并拼接多租户条件
     *
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }
}

Mybatis-plus多租户插件配置:
mybatis-plus多租户插件官方文档

yml可配置具体的表不走多租户数据隔离(可设计成starter给其他服务使用)

ignore-tenant-tables: user_info,user1
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
public class MybatisConfig {
	//忽略租户过滤表集合
    @Value("${ignore-tenant-tables:}")
    private String ignoreTenantTables;

    // 最新版
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        if (!StringUtils.isBlank(ignoreTenantTables)) {
            List<String> ignoreTenantTableList = Arrays.asList(StringUtils.split(ignoreTenantTables, ","));
            if (!CollectionUtils.isEmpty(ignoreTenantTableList)) {
                //多租户插件
                interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
                    @Override
                    public Expression getTenantId() {
		                return new LongValue(1);
                    }

                    @Override
                    public boolean ignoreTable(String tableName) {
                    	//这是我的一个上下文类,忽略当前线程使用租户数据隔离
                        if (MybatisTenantContextHolder.isNoTenant()) {
                            return true;
                        }
                        return ignoreTenantTableList.contains(tableName);
                    }
                }));
            }
        }

        //分页插件
        //interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    @Bean
    public MybatisAutoFillHandler mybatisAutoFillHandler() {
        return new MybatisAutoFillHandler();
    }
}

定义MybatisTenantContextHolder(可设计成注解加到方法定义前面,如下)

public class MybatisTenantContextHolder {

    private static final ThreadLocal<TenantContext> TENANT_CONTEXT_THREAD_LOCAL = new ThreadLocal<>();

    public static void set(TenantContext context) {
        TENANT_CONTEXT_THREAD_LOCAL.set(context);
    }

    public static TenantContext get() {
        return TENANT_CONTEXT_THREAD_LOCAL.get();
    }

    public static void clear() {
        TENANT_CONTEXT_THREAD_LOCAL.remove();
    }

    public static boolean isNoTenant() {
        TenantContext tenantContext = TENANT_CONTEXT_THREAD_LOCAL.get();
        if (tenantContext == null) {
            return false;
        }
        return tenantContext.isNoTenant();
    }

}

注解类@NoTenant

/**
 * 无租户注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface NoTenant {

    String name() default "";
}


切面类NoTenantAspect

@Aspect
@Component
@Slf4j
public class NoTenantAspect {


    /**
     * 切入点
     */
    @Pointcut("@annotation(com.example.NoTenant)")
    public void pointcut() {

    }

    /**
     * 环绕操作
     *
     * @param point 切入点
     * @return 原方法返回值
     * @throws Throwable 异常信息
     */
    @Around(value = "pointcut()")
    public Object aroundLog(ProceedingJoinPoint point) throws Throwable {
        try {
            MybatisTenantContextHolder.set(TenantContext.builder().noTenant(true).build());
            return point.proceed();
        } finally {
            MybatisTenantContextHolder.clear();
        }
    }

}

二、使用方式
1.使用sql为数据库所需要租户隔离的表增加租户ID字段

SELECT
	TABLE_NAME,
	concat( 'ALTER TABLE ', TABLE_SCHEMA, '.', TABLE_NAME, " ADD COLUMN `tenant_id` bigint DEFAULT '0' COMMENT '租户id';" ) t_sql 
FROM
	information_schema.TABLES t 
WHERE
	TABLE_SCHEMA = 'test' 
	AND TABLE_NAME NOT IN (
	'user')

2.忽略某个sql执行时不加租户ID的方法@InterceptorIgnore

 //(on)不增加tenant_id的查询条件,默认是增加(off)
 @InterceptorIgnore(tenantLine = "on")
 Integer myCount();

三、案例分析
案例一:初始化数据使用了ApplicationRunner和CommandLine
有些数据需要初始化的时候执行,所有租户都需要执行,可以用@Subscribe在注册完毕之后通过RPC远程调用获取所有租户信息,然后for循环遍历执行。

@Subscribe
public void initCache(ServiceReadyEvent serviceReadyEvent) {


}

案例二:sql中表需要加别名,否则会出现 Cause: java.sql.SQLIntegrityConstraintViolationException: Column ‘tenant_id’ in where clause is ambiguous

 with recursive cte as
           (
               select id,parent_id,`name`,cast(id as char(128) ) order_field from department as d where delete_flag = 0 AND id = #{departmentId}
               union all
               select c.id,c.parent_id,c.name,concat(order_field,',',cte.parent_id) order_field from department c, cte where c.delete_flag = 0 AND c.id = cte.parent_id
           )
    select * from cte as c order by `order_field` desc;

案例三:mapper.xml复杂sql,mybatis plus的sqlparse解析器解析复杂sql:Failed to process, please exclude the tableName or statementId
无法解析复杂sql,可以设置忽略该sql多租户插件追加,自己补全租户id过滤

    @InterceptorIgnore(tenantLine = "on")
	@Select("复杂炫酷sql")
    List<UserRoleExt> userRoleList(Long userId);

案例四:线程级别白名单(例如:定时任务)
使用MybatisTenantContextHolder设置当前线程标志位:noTenant开关为true即可,用完记得掉clear()方法;
改进版用注解@NoTenant

@Scheduled(cron = "0 */15 * * * ?")
private void commit() {
    MybatisTenantContextHolder.set(TenantContext.builder().noTenant(true).build());
    reportCommitInnerService.commit();
    MybatisTenantContextHolder.clear();
}

测试结果:
提示:这里写了单元测试类TenantTest,运行结果就不细说了

@SpringBootTest
public class TenantTest {
    @Resource
    private UserInfoMapper mapper;
    @Resource
    private UserAddrMapper userAddrMapper;

    @Test
    public void aInsert() {
        UserInfo userInfo = new UserInfo();
        userInfo.setName("一一");
        Assertions.assertTrue(mapper.insert(userInfo) > 0);
        userInfo = mapper.selectById(userInfo.getId());
        Assertions.assertTrue(1 == userInfo.getTenantId());
    }


    @Test
    public void bDelete() {
        Assertions.assertTrue(mapper.deleteById(3L) > 0);
    }


    @Test
    public void cUpdate() {
        Assertions.assertTrue(mapper.updateById(new UserInfo().setId(1L).setName("mp")) > 0);
    }

    @Test
    public void dSelect() {
        List<UserInfo> userInfoList = mapper.selectList(null);
        userInfoList.forEach(u -> Assertions.assertTrue(1 == u.getTenantId()));
    }


    @Test
    public void addrSelect() {
        List<UserAddr> userAddrList = userAddrMapper.selectList(null);
        userAddrList.forEach(u -> System.out.println(u));
    }

    /**
     * 自定义SQL:默认也会增加多租户条件
     * 参考打印的SQL
     */
    @Test
    public void manualSqlTenantFilterTest() {
        System.out.println(mapper.myCount());
    }

    @Test
    public void testTenantFilter() {
        mapper.getAddrAndUser(null).forEach(System.out::println);
        mapper.getAddrAndUser("add").forEach(System.out::println);
        mapper.getUserAndAddr(null).forEach(System.out::println);
        mapper.getUserAndAddr("J").forEach(System.out::println);
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值