mybatisplus 打包war mapper空指针_基于Mybatisplus实现多租户架构

本文介绍了如何基于MybatisPlus实现多租户架构,重点讲解了第三层隔离级别的解决方案,包括添加租户字段、配置SQL解析器、处理租户逻辑、解决线程请求头携带租户信息的问题,以及如何处理不需要租户过滤的SQL语句。同时,文中提到了联表查询的注意事项,并提供了避免常见错误的建议。
摘要由CSDN通过智能技术生成

多租户(Multi-Tenant)是SaaS中的一个重要概念,它是一种软件架构技术,在多个租户的环境下,共享同一套系统实例,并且租户之间的数据具有隔离性,也就是说一个租户不能去访问其他租户的数据。基于不同的隔离级别,通常具有下面三种实现方案:

  1. 每个租户使用独立DataBase,隔离级别高,性能好,但成本大

  2. 租户之间共享DataBase,使用独立的Schema

  3. 租户之间共享Schema,在表上添加租户字段,共享数据程度最高,隔离级别最低。

Mybatis-plus在第3层隔离级别上,提供了基于分页插件的多租户的解决方案,我们对此来进行介绍。在正式开始前,首先做好准备工作创建两张表,在基础字段后都添加租户字段tenant_id:

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL,
  `name` varchar(20) DEFAULT NULL,
  `phone` varchar(11) DEFAULT NULL,
  `address` varchar(64) DEFAULT NULL,
  `tenant_id` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
)
CREATE TABLE `dept` (
  `id` bigint(20) NOT NULL,
  `dept_name` varchar(64) DEFAULT NULL,
  `comment` varchar(128) DEFAULT NULL,
  `tenant_id` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
)

在项目中导入需要的依赖:

<dependency>
    <groupId>com.baomidougroupId>
    <artifactId>mybatis-plus-boot-starterartifactId>
    <version>3.3.2version>
dependency>
<dependency>
    <groupId>com.github.jsqlparsergroupId>
    <artifactId>jsqlparserartifactId>
    <version>3.1version>
dependency>

Mybatis-plus 配置类:

@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
public class MybatisPlusConfig {
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();

        List sqlParserList=new ArrayList<>();
        TenantSqlParser tenantSqlParser=new TenantSqlParser();
        tenantSqlParser.setTenantHandler(new TenantHandler() {@Overridepublic Expression getTenantId(boolean select) {               
                String tenantId = "3";return new StringValue(tenantId);
            }@Overridepublic String getTenantIdColumn() {return "tenant_id";
            }@Overridepublic boolean doTableFilter(String tableName) {return false;
            }
        });
        sqlParserList.add(tenantSqlParser);
        paginationInterceptor.setSqlParserList(sqlParserList);return paginationInterceptor;
    }
}

这里主要实现的功能:

  • 创建SQL解析器集合

  • 创建租户SQL解析器

  • 设置租户处理器,具体处理租户逻辑

这里暂时把租户的id固定写成3,来进行测试。测试执行全表语句:

public List getUserList() {
    return userMapper.selectList(new LambdaQueryWrapper().isNotNull(User::getId));
}

使用插件解析执行的SQL语句,可以看到自动在查询条件后加上了租户过滤条件:

198f2d292cfaf33391570bef92d5a4f2.png

那么在实际的项目中,怎么将租户信息传给租户处理器呢,根据情况我们可以从缓存或者请求头中获取,以从Request请求头获取为例:

@Override
public Expression getTenantId(boolean select) {
    ServletRequestAttributes attributes=(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = attributes.getRequest();
    String tenantId = request.getHeader("tenantId");
    return new StringValue(tenantId);
}

前端在发起http请求时,在Header中加入tenantId字段,后端在处理器中获取后,设置为当前这次请求的租户过滤条件。

如果是基于请求头携带租户信息的情况,那么在使用中可能会遇到一个坑,如果当使用多线程的时候,新开启的异步线程并不会自动携带当前线程的Request请求。

@Override
public List getUserListByFuture() {
    Callable getUser=()-> userMapper.selectList(new LambdaQueryWrapper().isNotNull(User::getId));
    FutureTask> future=new FutureTask<>(getUser);new Thread(future).start();try {return future.get();
    } catch (Exception e) {
        e.printStackTrace();
    }return null;
}

执行上面的方法,可以看出是获取不到当前的Request请求的,因此无法获得租户id,会导致后续报错空指针异常:

45beb9ada6727b8b5e8e5e02192cbc8f.png

修改的话也非常简单,开启RequestAttributes的子线程共享,修改上面的代码:

@Override
public List getUserListByFuture() {
    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    Callable getUser=()-> {
        RequestContextHolder.setRequestAttributes(sra, true);
        return userMapper.selectList(new LambdaQueryWrapper().isNotNull(User::getId));
    };
    FutureTask> future=new FutureTask<>(getUser);new Thread(future).start();try {return future.get();
    } catch (Exception e) {
        e.printStackTrace();
    }return null;
}

这样修改后,在异步线程中也能正常的获取租户信息了。

那么,有的小伙伴可能要问了,在业务中并不是所有的查询都需要过滤租户条件啊,针对这种情况,有两种方式来进行处理。

1、如果整张表的所有SQL操作都不需要针对租户进行操作,那么就对表进行过滤,修改doTableFilter方法,添加表的名称:

@Override
public boolean doTableFilter(String tableName) {
    List IGNORE_TENANT_TABLES= Arrays.asList("dept");return IGNORE_TENANT_TABLES.stream().anyMatch(e->e.equalsIgnoreCase(tableName));
}

这样,在dept表的所有查询都不进行过滤:

a4bb45812f3a5ad1be0907caa51ba34f.png

2、如果有一些特定的SQL语句不想被执行租户过滤,可以通过@SqlParser注解的形式开启,注意注解只能加在Mapper接口的方法上:

@SqlParser(filter = true)
@Select("select * from user where name =#{name}")
User selectUserByName(@Param(value="name") String name);

或在分页拦截器中指定需要过滤的方法:

@Bean
public PaginationInterceptor paginationInterceptor() {
    PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
    paginationInterceptor.setSqlParserFilter(metaObject->{
        MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);
        // 对应Mapper、dao中的方法
        if("com.cn.tenant.dao.UserMapper.selectUserByPhone".equals(ms.getId())){
            return true;
        }
        return false;
    });
    ...
}

上面这两种方式实现的功能相同,但是如果需要过滤的SQL语句很多,那么第二种方式配置起来会比较麻烦,因此建议通过注解的方式进行过滤。

除此之外,还有一个比较容易踩的坑就是在复制Bean时,不要复制租户id字段,否则会导致SQL语句报错:

public void createSnapshot(Long userId){
    User user = userMapper.selectOne(new LambdaQueryWrapper().eq(User::getId, userId));
    UserSnapshot userSnapshot=new UserSnapshot();
    BeanUtil.copyProperties(user,userSnapshot);
    userSnapshotMapper.insert(userSnapshot);
}

查看报错可以看出,本身Bean的租户字段不为空的情况下,SQL又自动添加一次租户查询条件,因此导致了报错:

0b9a7abd754238442dc336bfb18b6783.png

我们可以修改复制Bean语句,手动忽略租户id字段,这里使用的是hutool的BeanUtil工具类,可以添加忽略字段。

BeanUtil.copyProperties(user,userSnapshot,"tenantId");

在忽略了租户id的拷贝后,查询可以正常执行。

最后,再来看一下对联表查询的支持,首先看一下包含子查询的SQL:

@Select("select * from user where id in (select id from user_snapshot)")
List selectSnapshot();

查看执行结果,可以看见,在子查询的内部也自动添加的租户查询条件:

153a1cf3772106a5f0796d1f9cc5e045.png

再来看一下使用Join进行联表查询:

@Select("select u.* from user u left join user_snapshot us on u.id=us.id")
List selectSnapshot();

同样,会在左右两张表上都添加租户的过滤条件:

f98df1859f97b3d16cd37cbeb2f9b3fd.png

再看一下不使用Join的普通联表查询:

@Select("select u.* from user u ,user_snapshot us,dept d where u.id=us.id and d.id is not null")
List selectSnapshot();

35e6cfcdb9f4bb3a97aaf9423fb11b3a.png

查看执行结果,可以看见在这种情况下,只在FROM关键字后面的第一张表上添加了租户的过滤条件,因此如果使用这种查询方式,需要额外注意,用户需要手动在SQL语句中添加租户过滤。

公众号后台回复

"面试"---领取大厂面试资料

"导图"---领取24张Java后端学习笔记导图

"架构"---领取29本java架构师电子书籍

"实战"---领取springboot实战项目

"视频"---领取最新java架构师视频

"加群"---加入学习交流群

478a0b943a9b8193aba10ac8f13703af.png

扫码关注公众号

有趣、深入、直接

与你聊聊技术

觉得有用,一键三连吧~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值