写点什么

基于 Mybatis-plus 实现多租户架构

用户头像
码农参上
关注
发布于: 2 小时前

多租户(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.baomidou</groupId>    <artifactId>mybatis-plus-boot-starter</artifactId>    <version>3.3.2</version></dependency><dependency>    <groupId>com.github.jsqlparser</groupId>    <artifactId>jsqlparser</artifactId>    <version>3.1</version></dependency>
复制代码


Mybatis-plus 配置类:


@EnableTransactionManagement(proxyTargetClass = true)@Configurationpublic class MybatisPlusConfig {    @Bean    public PaginationInterceptor paginationInterceptor() {        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
List<ISqlParser> sqlParserList=new ArrayList<>(); TenantSqlParser tenantSqlParser=new TenantSqlParser(); tenantSqlParser.setTenantHandler(new TenantHandler() { @Override public Expression getTenantId(boolean select) { String tenantId = "3"; return new StringValue(tenantId); }
@Override public String getTenantIdColumn() { return "tenant_id"; }
@Override public boolean doTableFilter(String tableName) { return false; } });
sqlParserList.add(tenantSqlParser); paginationInterceptor.setSqlParserList(sqlParserList); return paginationInterceptor; }}
复制代码


这里主要实现的功能:


  • 创建 SQL 解析器集合

  • 创建租户 SQL 解析器

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


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


public List<User> getUserList() {    return userMapper.selectList(new LambdaQueryWrapper<User>().isNotNull(User::getId));}
复制代码


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



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


@Overridepublic 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 请求。


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


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



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


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


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


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


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


@Overridepublic boolean doTableFilter(String tableName) {    List<String> IGNORE_TENANT_TABLES= Arrays.asList("dept");    return IGNORE_TENANT_TABLES.stream().anyMatch(e->e.equalsIgnoreCase(tableName));}
复制代码


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



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


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


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


@Beanpublic 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<User>().eq(User::getId, userId));    UserSnapshot userSnapshot=new UserSnapshot();    BeanUtil.copyProperties(user,userSnapshot);    userSnapshotMapper.insert(userSnapshot);}
复制代码


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



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


BeanUtil.copyProperties(user,userSnapshot,"tenantId");
复制代码


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


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


@Select("select * from user where id in (select id from user_snapshot)")List<User> selectSnapshot();
复制代码


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



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


@Select("select u.* from user u left join user_snapshot us on u.id=us.id")List<User> selectSnapshot();
复制代码


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



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


@Select("select u.* from user u ,user_snapshot us,dept d where u.id=us.id and d.id is not null")List<User> selectSnapshot();
复制代码



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


如果文章对您有所帮助,欢迎关注公众号 码农参上

加号主好友,来围观朋友圈啊~


发布于: 2 小时前阅读数: 4
用户头像

码农参上

关注

公众号:码农参上 2021.03.30 加入

还未添加个人简介

评论

发布
暂无评论
基于Mybatis-plus实现多租户架构