SaaS化多租户实现的两种方法

SaaS化多租户实现的两种方法

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

SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作实际需求,通过互联网向厂商订购所需的应用软件服务, 按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。 SaaS服务通常基于一套标准软件系统为成百上千的不同客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户之间数据和配置的隔离,从而保证每个租户数据的安全与隐私, 以及用户对诸如界面、业务逻辑、数据结构等的个性化需求。由于SaaS同时支持多个租户,每个租户又有很多用户,这对支撑软件的基础设施平台的性能、稳定性和扩展性提出很大挑战。

方法 一、租户(tenant_id)隔离

这种方法比较简单,在每张表里添加一个字段tenant_id,给每个企业(租户)一个唯一tenant_id,那么在SQL的一切增删改查都带上tenant_id,即可实现租户隔离。

如何自动带上租户id,无需每次都手动在sql上添加tenant_id?
使用mybatis-plugin可以做到

步骤 1、写一个拦截器
@Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class,
                Integer.class})
})
public class CustomerInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //todo 拦截逻辑
        System.out.println("");

        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        String originalSql = statementHandler.getBoundSql().getSql();
        //实际开发中从登录用户去获取他的tenant_id 
        String modifiedSql = originalSql + " AND tenant_id = '" + tenant_id + "'";
        ReflectUtil.setFieldValue(statementHandler.getBoundSql(), "sql", modifiedSql);

        return invocation.proceed();
    }
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target,this);
    }
    @Override
    public void setProperties(Properties properties) {
        //设置属性
    }
}
步骤 2、注册插件
@Configuration
public class MybatisConfig {
    @Bean
    public String myInterceptor(SqlSessionFactory sqlSessionFactory) {
        sqlSessionFactory.getConfiguration().addInterceptor(new CustomerInterceptor());
        return "interceptor";
    }
}

方法二、动态数据源(重点)

本文重点要讲的是使用动态数据源实现动态切换数据库,来实现多租户自由切换
本文使用的是mybatis-flex

步骤1、注册租户和数据源到数据库

即,把租户的唯一信息和分配给租户的数据源一一对应,存入数据库,例如:

CREATE TABLE `datasource` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `display_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '显示名称',
  `db_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '链接默认数据库',
  `schema_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '数据库schema',
  `pool_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '连接池名称必须唯一',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '描述',
  `db_host` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '数据库地址',
  `db_port` int NOT NULL COMMENT '数据库端口',
  `db_user` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户',
  `db_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '密码',
  `db_driver` varchar(25) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '驱动',
  `connect_params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '连接参数',
  `create_time` timestamp NULL DEFAULT NULL,
  `update_time` timestamp NULL DEFAULT NULL,
  `create_by` timestamp NULL DEFAULT NULL,
  `update_by` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `datasource_pool_name_uindex` (`pool_name`) USING BTREE,
  KEY `database_creator_id_index` (`user_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=159 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;

示例数据:
在这里插入图片描述

步骤2、程序启动完成把数据连接信息加载到JVM
@Component
@Order(1)
public class InitialDataSource implements CommandLineRunner {

    public static final String DATASOURCE_MYSQL_COMMON_PARAMS_URL = "jdbc:%s://%s:%s/%s?%s";
    public static final String DATASOURCE_PGSQL_COMMON_PARAMS_URL = "jdbc:%s://%s:%s/%s?%s&%s";
	//这是数据源(步骤1提到的)表的mapper接口
    @Resource
    private DatasourceMapper datasourceMapper;

    @Override
    public void run(String... args) throws Exception {
        //1.清空内存中的数据源
        DataSourceKey.clear();
        //2.把数据库的datasource查询出来
        List<Datasource> fillSubmittals = datasourceMapper.selectAll();
        //3.动态添加新的数据源 FlexDataSource来自于mybatis-flex
        FlexDataSource flexDataSource = FlexGlobalConfig.getDefaultConfig().getDataSource();
        fillSubmittals.forEach(item -> addDatasourceItem(flexDataSource, item));
    }

    /**
     * 组装-添加数据源
     *
     * @param flexDataSource
     * @param item
     */
    public void addDatasourceItem(FlexDataSource flexDataSource, Datasource item) {

        DruidDataSource druidDataSource = buildDruidDataSource(item);
        //数据源信息加载到内存
        addIntoJVMDynamicPool(flexDataSource, item.getPoolName(),druidDataSource);

    }

    /**
     * 数据源信息加载到内存
     * @param flexDataSource
     * @param poolName
     * @param druidDataSource
     * @return
     */
    public void addIntoJVMDynamicPool(FlexDataSource flexDataSource, String poolName, DruidDataSource druidDataSource){
        flexDataSource.addDataSource(poolName,druidDataSource);
    }

    public DruidDataSource buildDruidDataSource(Datasource item){
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(getCommonUrl(item));
        druidDataSource.setDriverClassName(DriverEnum.findByEnumDescription(item.getDbDriver()).getDriverClass());
        druidDataSource.setUsername(item.getDbUser());
        druidDataSource.setPassword(item.getDbPassword());
        druidDataSource.setValidationQuery("select 1");
        return druidDataSource;
    }

    public String getCommonUrl(Datasource datasource){
        String url = null;
        if(DriverEnum.MYSQL.getDescription().equals(datasource.getDbDriver())){

             url = String.format(DATASOURCE_MYSQL_COMMON_PARAMS_URL,
                    datasource.getDbDriver(),
                    datasource.getDbHost(),
                    datasource.getDbPort(),
                    StringUtils.hasText(datasource.getDbName()) ? datasource.getDbName() : "",
                    StringUtils.hasText(datasource.getConnectParams()) ? datasource.getConnectParams() : "");

        }else if(DriverEnum.POSTGRES.getDescription().equals(datasource.getDbDriver())){

            url =String.format(DATASOURCE_PGSQL_COMMON_PARAMS_URL,
                    datasource.getDbDriver(),
                    datasource.getDbHost(),
                    datasource.getDbPort(),
                    StringUtils.hasText(datasource.getDbName()) ? datasource.getDbName() : "",
                    StringUtils.hasText(datasource.getSchemaName()) ? "currentSchema="+datasource.getSchemaName() : "",
                    StringUtils.hasText(datasource.getConnectParams()) ? datasource.getConnectParams() : "");

        }
        return url;
    }

}

用到的枚举:

@Getter
public enum DriverEnum {
    /**
     * pg
     */
    POSTGRES(0, "postgresql", "org.postgresql.Driver", DbType.postgresql, "postgres"),
    /**
     * mysql
     */
    MYSQL(1, "mysql", "com.mysql.cj.jdbc.Driver", DbType.mysql,"mysql"),

    /**
     * ck
     */
    CLICK_HOUSE(2, "clickhouse", "com.clickhouse.jdbc.ClickHouseDriver", DbType.clickhouse, "clickhouse");

    private final int index;
    // bi 记录驱动
    private final String description;
    private final String driverClass;
    // metabase 记录驱动
    private final String engine;

    private final DbType analysisType;

    DriverEnum(int index, String description, String driverClass, DbType analysisType, String engine){
        this.index = index;
        this.description = description;
        this.driverClass = driverClass;
        this.analysisType = analysisType;
        this.engine = engine;
    }

    private static final Map<String, DriverEnum> DESCRIPTION_ENUMS_MAP = Map.of(
            DriverEnum.POSTGRES.getDescription(), DriverEnum.POSTGRES,
            DriverEnum.MYSQL.getDescription(), DriverEnum.MYSQL,
            DriverEnum.CLICK_HOUSE.getDescription(), DriverEnum.CLICK_HOUSE
    );

    private static final Map<String, DbType> ANALYSISTYPE_ENUMS_MAP = Map.of(
            DriverEnum.POSTGRES.getDescription(), DbType.postgresql,
            DriverEnum.MYSQL.getDescription(), DbType.mysql,
            DriverEnum.CLICK_HOUSE.getDescription(), DbType.clickhouse
    );


    /**
     * 判断参数合法性
     */
    public static boolean isValidName(String name) {
        for (DriverEnum cardStatus : DriverEnum.values()) {
            if (cardStatus.getDescription().equals(name)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 根据描述查找枚举
     * @param description 描述
     * @return 枚举
     */
    public static DriverEnum findByEnumDescription(String description){
        return DESCRIPTION_ENUMS_MAP.getOrDefault(description, DriverEnum.POSTGRES);
    }

    /**
     * 根据描述查找SQL解析器
     * @param description 描述
     * @return 枚举
     */
    public static DbType findAnalysisTypeByDescription(String description){
        return ANALYSISTYPE_ENUMS_MAP.getOrDefault(description, DbType.postgresql);
    }
}
步骤3、业务使用(只列出核心)

	@Resource
    private JdbcTemplate jdbcTemplate;
    
 public List<FillSubmittal> queryAll() {

        String sql1 = "select * from datasource";
        //设置数据库pool-name   与步骤一的表里的pool_name对应
        DataSourceKey.use("c-1");
        executeSql(sql1);
        
        String sql2 = "select * from nc_fill_table_24_4lg0aa20f4rw9r";
        DataSourceKey.use("c-2");
        executeSql(sql2);

        String sql3 = "select * from user_info";
        DataSourceKey.use("c-3");
        executeSql(sql3);

        String sql4 = "select * from sys_role";
        DataSourceKey.use("c-4");
        executeSql(sql4);
        return null;
    }
 public void executeSql(String sql){
        List<Map<String, Object>> list =jdbcTemplate.queryForList(sql);
        log.info(JSON.toJSONString(list));
    }

这样,每个租户注册的时候就分配一个数据源,在使用时,根据租户灯笼裤信息获取到他对应的数据源信息,就可以通过DataSourceKey.use设置当前租户要用的数据源,实现动态切换


流程如图:
①租户注册(红色),把租户信息(租户对应唯一poolName)和数据源绑定存入数据库
②程序启动(绿色),把数据加载到JVM中
③使用时(蓝色),根据不用的pool_name(与租户一一对应)获取并切换到对应的数据源。那么执行sql得到的就是对应数据源的数据,注意:这里执行sql使用的是jdbcTemplate了

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

神雕大侠mu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值