SaaS多租户及在Web应用中实现

大家好,我是闲着,最近在做一个项目,需要将其改造为支持多租户,因为使用的是Java,因此了解了下MyBatis-Plus的多租户插件,在此做下记录。

MyBatis-Plus多租户插件文档

多租户是一种软件架构技术,在多用户的环境下,共有同一套系统,并且要注意数据之间的隔离性。

本文针对下面两个问题,提供解决方案:

多租户的产品,想在表内级别上,实现租户数据隔离(分表、分库方案不在本文讨论范围内)。 ToB、ToG类型的软件产品,需要实现数据权限鉴权。例如用户数据、部门数据、租户数据等不同级别的鉴权。

一. SaaS多租户简介

1.1 SaaS多租户

SaaS,是Software-as-a-Service的缩写名称,意思为软件即服务,即通过网络提供软件服务。

SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得Saas平台供应商提供的服务。

SaaS服务通常基于一套标准软件系统为成百上千的不同客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户之间数据和配置的隔离,从而保证每个租户数据的安全与隐私,以及用户对诸如界面、业务逻辑、数据结构等的个性化需求。由于SaaS同时支持多个租户,每个租户又有很多用户,这对支撑软件的基础设施平台的性能、稳定性和扩展性提出很大挑战。

1.2 多租户

多租户技术(英语:multi-tenancy technology)或称多重租赁技术,是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。

多租户技术可以实现多个租户之间共享系统实例,同时又可以实现租户的系统实例的个性化定制。通过使用多租户技术可以保证系统共性的部分被共享,个性的部分被单独隔离。通过在多个租户之间的资源复用,运营管理维护资源,有效节省开发应用的成本。

多租户技术的实现重点,在于不同租户间应用程序环境的隔离(application context isolation)以及数据的隔离(data isolation),以维持不同租户间应用程序不会相互干扰,同时数据的保密性也够强。

1.3 多租户数据隔离有三种方案

1.3.1 独立数据库

这是第一种方案,即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。

优点:

为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;
如果出现故障,恢复数据比较简单。

缺点:

增大了数据库的安装数量,随之带来维护成本和购置成本的增加。
这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。
1.3.2 共享数据库,隔离数据架构

这是第二种方案,即多个或所有租户共享Database,但一个Tenant一个Schema。

优点:

为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。

缺点:

如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据;
如果需要跨租户统计数据,存在一定困难。
1.3.3 共享数据库,共享数据架构

这是第三种方案,即租户共享同一个Database、同一个Schema,但在表中通过TenantID区分租户的数据。这是共享程度最高、隔离级别最低的模式。

优点:

三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

缺点:

隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;
数据备份和恢复最困难,需要逐表逐条备份和还原。

如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本,这种方案最适合。

如果采用方案三,MybatisPlus就提供了一种多租户的解决方案,实现方式是基于多租户插件TenantLineInnerInterceptor进行实现的。

二. MybatisPlus多租户插件

MybatisPlus提供了租户处理器( TenantId 行级 ),租户之间共享数据库,共享数据架构,通过表字段(租户ID)进行数据逻辑隔离。

注意事项:

多租户 != 权限过滤,不要乱用,租户之间是完全隔离的!!! 启用多租户后所有执行的method的sql都会进行处理. 自写的sql请按规范书写(sql涉及到多个表的每个表都要给别名,特别是 inner join 的要写标准的 inner join)

TenantLineInnerInterceptor是MybatisPlus中提供的多租户插件,其使用方法大致分为下面4步:

        <!-- Mybatis-Plus 增强CRUD -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>

        <!-- Mybatis-Plus 扩展插件 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-extension</artifactId>
            <version>3.5.1</version>
        </dependency>

2.1 表及实体类添加租户ID

租户ID一般用tenant_id

2.2 application文件中添加多租户配置和新增配置属性类

(1)设置环境变量,配置拦截规则:可以设置是否开启多租户,对多租户的表设置白名单忽略多租户拦截等。

#多租户配置
tenant:
  enable: true
  column: tenant_id
  filterTables:
  ignoreTables:
    - sys_app
    - sys_config
    - sys_dict_data
    - sys_dict_type
    - sys_logininfor
    - sys_menu
    - sys_notice
    - sys_oper_log
    - sys_role
    - sys_role_menu
    - sys_user
    - sys_user_role
  ignoreLoginNames:

例如sys_user表结构中,没有tenant_id多租户字段,那么多租户拦截器不拦截该表。

(2)多租户配置属性类

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;

/**
 * 多租户配置属性类
 *
 * @author hege
 * @Date 2023-08-25
 *
 */
@Data
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
    /**
     * 是否开启多租户
     */
    private Boolean enable = true;

    /**
     * 租户id字段名
     */
    private String column = "tenant_id";

    /**
     * 需要进行租户id过滤的表名集合
     */
    private List<String> filterTables;

    /**
     * 需要忽略的多租户的表,此配置优先filterTables,若此配置为空则启用filterTables
     */
    private List<String> ignoreTables;

    /**
     * 需要排除租户过滤的登录用户名
     */
    private List<String> ignoreLoginNames;
}

2.3 编写多租户处理器实现TenantLineHandler接口

import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.NullValue;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;

/**
 * 多租户处理器实现TenantLineHandler接口
 *
 * @author hege
 * @Date 2023-08-25
 */
public class MultiTenantHandler implements TenantLineHandler {

    private final TenantProperties properties;

    public MultiTenantHandler(TenantProperties properties) {
        this.properties = properties;
    }

    /**
     * 获取租户ID值表达式,只支持单个ID值 (实际应该从用户信息中获取)
     *
     * @return 租户ID值表达式
     */
    @Override
    public Expression getTenantId() {

        //实际应该从用户信息中获取
        if(SecurityUtils.getTenantLoginUser()!=null)
        {
            Long tenantId = SecurityUtils.getLoginUser().getUser().getRootPartyId();
            if(tenantId!=null)
            {
                return new LongValue(tenantId);
            }
        }

        return new LongValue(0);

    }

    /**
     * 获取租户字段名,默认字段名叫: tenant_id
     *
     * @return 租户字段名
     */
    @Override
    public String getTenantIdColumn() {
        return properties.getColumn();
    }

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

        //忽略指定用户对租户的数据过滤
        List<String> ignoreLoginNames=properties.getIgnoreLoginNames();
        String loginName=SecurityUtils.getTenantUsername();
        if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){
            return true;
        }

        //忽略指定表对租户数据的过滤
        List<String> ignoreTables = properties.getIgnoreTables();
        if (null != ignoreTables && ignoreTables.contains(tableName)) {
            return true;
        }

        return false;
    }
}

对应用户服务工具类

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 安全服务工具类
 *
 * @author hege
 */
public class SecurityUtils {


    /**
     * 获取多租户用户
     **/
    public static LoginUser getTenantLoginUser() {
        try {

            LoginUser loginUser = null;

            // 获取安全上下文对象,就是那个保存在ThreadLocal里面的安全上下文对象,总是不为null(如果不存在,则创建一个authentication属性为null的empty安全上下文对象)
            SecurityContext securityContext = SecurityContextHolder.getContext();
            // 获取当前认证了的 principal(当事人) 或者 request token (令牌); 如果没有认证,会是 null,该例子是认证之后的情况
            Authentication authentication = securityContext.getAuthentication();

            if(authentication!=null)
            {
                if(authentication.getPrincipal()!=null)
                {
                    if (authentication.getPrincipal() instanceof LoginUser) {
                        loginUser = (LoginUser) authentication.getPrincipal();
                    }

                }
            }
            return loginUser;

        } catch (Exception e) {
            e.printStackTrace();
            throw new ServiceException("获取用户信息异常", HttpStatus.UNAUTHORIZED);
        }
    }

}

2.4 MybatisPlus配置类启用多租户拦截插件

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * Mybatis Plus 配置
 *
 * @author hege
 */
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
@EnableConfigurationProperties(TenantProperties.class)
public class MybatisPlusConfig {

    /**
     * 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
     *
     * @param tenantProperties
     * @return
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {

        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        if (Boolean.TRUE.equals(tenantProperties.getEnable())) {
            // 启用多租户插件拦截
            interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));
        }

        // 分页插件
        interceptor.addInnerInterceptor(paginationInnerInterceptor());
        // 乐观锁插件
        interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
        // 阻断插件
        interceptor.addInnerInterceptor(blockAttackInnerInterceptor());

        return interceptor;
    }

}

2.5 特定SQL语句忽略拦截

在一些场景下,无需多租户拦截,或者对于一些超级管理员使用的接口,希望跨租户查询、免数据鉴权时,可以通过下面几种方式实现忽略拦截:

使用MybatisPlus框架自带的@InterceptorIgnore注解,以用在Mapper类上,也可以用在方法上

添加超级用户账号白名单,在自定义的Handler里进行逻辑判断,跳过拦截

添加数据表白名单,在自定义的Handler里进行逻辑判断,跳过拦截
 /**
     * 使用@InterceptorIgnore注解,忽略多租户拦截 <br/>
     * 注解@InterceptorIgnore可以用在Mapper类上,也可以用在方法上
     *
     * @param id
     * @return
     */
    @InterceptorIgnore(tenantLine = "true")
    UserOrgVO myFindByIdNoTenant(@Param(value = "id") Long id);

2.6 执行结果

针对MybatisPlus提供的API、自定义Mapper中的statement均可正常拦截,会在SQL执行增删改查的时候自动加上tenant_id。

版权声明: 闲者 发表于 2024-02-21
转载请注明: SaaS多租户及在Web应用中实现 | SaaS多租户及在Web应用中实现 - 无界文档,SaaS多租户及在Web应用中实现

评论区

暂无评论...